fedipod 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/architecture.svg +89 -0
- package/bin/fedipod.mjs +2303 -0
- package/cli.md +243 -0
- package/groups.md +85 -0
- package/gui.md +99 -0
- package/lib/account.mjs +87 -0
- package/lib/admin.mjs +1606 -0
- package/lib/atproto.mjs +310 -0
- package/lib/bskyfeed.mjs +166 -0
- package/lib/bskygroup.mjs +162 -0
- package/lib/c2s.mjs +305 -0
- package/lib/certs.mjs +265 -0
- package/lib/deliver.mjs +249 -0
- package/lib/directory.mjs +90 -0
- package/lib/embed.mjs +214 -0
- package/lib/export-collections.mjs +161 -0
- package/lib/front-core.mjs +322 -0
- package/lib/gateway-core.mjs +115 -0
- package/lib/guard.mjs +213 -0
- package/lib/home.mjs +161 -0
- package/lib/httpsig.mjs +109 -0
- package/lib/import.mjs +357 -0
- package/lib/intake.mjs +1710 -0
- package/lib/keys.mjs +139 -0
- package/lib/lease.mjs +190 -0
- package/lib/localapi.mjs +87 -0
- package/lib/mastoapi.mjs +1588 -0
- package/lib/migrate.mjs +127 -0
- package/lib/oidc-auth.mjs +53 -0
- package/lib/podrdf.mjs +132 -0
- package/lib/ports.mjs +27 -0
- package/lib/publisher.mjs +1003 -0
- package/lib/remote.mjs +303 -0
- package/lib/safefetch.mjs +235 -0
- package/lib/setup.mjs +262 -0
- package/lib/social.mjs +505 -0
- package/lib/storage.mjs +170 -0
- package/lib/store.mjs +470 -0
- package/lib/streaming.mjs +154 -0
- package/lib/tagfeed.mjs +158 -0
- package/lib/ua.mjs +29 -0
- package/lib/update.mjs +91 -0
- package/lib/webpush.mjs +98 -0
- package/lib/wire.mjs +762 -0
- package/package.json +60 -0
- package/phanpy/dist/404.html +40 -0
- package/phanpy/dist/_headers +2 -0
- package/phanpy/dist/apple-touch-icon.png +0 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
- package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
- package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
- package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
- package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
- package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
- package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
- package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
- package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
- package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
- package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
- package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
- package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
- package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
- package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
- package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
- package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
- package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
- package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
- package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
- package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
- package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
- package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
- package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
- package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
- package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
- package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
- package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
- package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
- package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
- package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
- package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
- package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
- package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
- package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
- package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
- package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
- package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
- package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
- package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
- package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
- package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
- package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
- package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
- package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
- package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
- package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
- package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
- package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
- package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
- package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
- package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
- package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
- package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
- package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
- package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
- package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
- package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
- package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
- package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
- package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
- package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
- package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
- package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
- package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
- package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
- package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
- package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
- package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
- package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
- package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
- package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
- package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
- package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
- package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
- package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
- package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
- package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
- package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
- package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
- package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
- package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
- package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
- package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
- package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
- package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
- package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
- package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
- package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
- package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
- package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
- package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
- package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
- package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
- package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
- package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
- package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
- package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
- package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
- package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
- package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
- package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
- package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
- package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
- package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
- package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
- package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
- package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
- package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
- package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
- package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
- package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
- package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
- package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
- package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
- package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
- package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
- package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
- package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
- package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
- package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
- package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
- package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
- package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
- package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
- package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
- package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
- package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
- package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
- package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
- package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
- package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
- package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
- package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
- package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
- package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
- package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
- package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
- package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
- package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
- package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
- package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
- package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
- package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
- package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
- package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
- package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
- package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
- package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
- package/phanpy/dist/compose/index.html +20 -0
- package/phanpy/dist/favicon.ico +0 -0
- package/phanpy/dist/index.html +73 -0
- package/phanpy/dist/logo-192.png +0 -0
- package/phanpy/dist/logo-512.png +0 -0
- package/phanpy/dist/logo-badge-72.png +0 -0
- package/phanpy/dist/logo-maskable-512.png +0 -0
- package/phanpy/dist/logo-monochrome-512.png +0 -0
- package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
- package/phanpy/dist/manifest.webmanifest +1 -0
- package/phanpy/dist/og-image-2.jpg +0 -0
- package/phanpy/dist/og-image.png +0 -0
- package/phanpy/dist/robots.txt +2 -0
- package/phanpy/dist/sw.js +25 -0
- package/phanpy/dist/version.json +1 -0
- package/run-agent.mjs +805 -0
- package/scripts/build-dist.mjs +37 -0
- package/scripts/verify-ui.mjs +60 -0
- package/ui/README.md +23 -0
- package/vendor/gate.cjs +132 -0
- package/vendor/idp-grant.cjs +394 -0
- package/vendor/jfetch.cjs +16 -0
- package/web/admin/admin.js +958 -0
- package/web/admin/bar.css +64 -0
- package/web/admin/bar.js +61 -0
- package/web/admin/client/client.js +66 -0
- package/web/admin/client/index.html +42 -0
- package/web/admin/index.html +493 -0
- package/web/admin/setup/index.html +172 -0
- package/web/admin/setup/setup.js +268 -0
- package/web/admin/window.css +55 -0
- package/web/admin/window.js +136 -0
- package/web/front/install.sh +69 -0
- package/web/front/new-account.html +424 -0
- package/web/front/run.html +138 -0
- package/web/front/solid-client-authn.bundle.js +2 -0
package/bin/fedipod.mjs
ADDED
|
@@ -0,0 +1,2303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// fedipod.mjs — CLI for the standalone pod-stored ActivityPub actor.
|
|
3
|
+
//
|
|
4
|
+
// fedipod start
|
|
5
|
+
// The one command. Finds a port that BINDS — starting from the recorded
|
|
6
|
+
// one, or 8030 — puts the agent behind it detached (logging to
|
|
7
|
+
// AP_HOME/agent.log, stoppable by pidfile), and opens the browser where
|
|
8
|
+
// there is something to do: /admin/setup/ when there is no identity yet,
|
|
9
|
+
// the client when there is. Already running? It says so and opens that.
|
|
10
|
+
// --no-open leaves the browser alone; --port names a starting port.
|
|
11
|
+
//
|
|
12
|
+
// fedipod setup
|
|
13
|
+
// Asks two things at the terminal — the handle, which is permanent and
|
|
14
|
+
// names the agent's own origin, and the port — then starts serving and
|
|
15
|
+
// opens http://<handle>.localhost:<port>/, where the rest is asked:
|
|
16
|
+
// new account or a pod you already have, identity provider, email, pod
|
|
17
|
+
// name, display name, person or group, bio, avatar, passwords. Nothing
|
|
18
|
+
// is created until you say so there, and the address you are about to
|
|
19
|
+
// take is shown before you do.
|
|
20
|
+
//
|
|
21
|
+
// fedipod setup --new-account --email you@example.org --handle you
|
|
22
|
+
// fedipod setup --pod https://you.solidcommunity.net/ \
|
|
23
|
+
// --issuer https://solidcommunity.net --email you@example.org --handle you
|
|
24
|
+
// Any identity flag (--new-account, --pod, --issuer, --email, --name,
|
|
25
|
+
// --pod-name, --group, --summary, --icon, --root, --keys) keeps setup
|
|
26
|
+
// entirely on the command line, as does a non-TTY stdin. --cli forces it.
|
|
27
|
+
//
|
|
28
|
+
// The password is prompted (or AP_PASSWORD) — used once to create the
|
|
29
|
+
// account and/or mint a revocable CSS client-credential, never stored.
|
|
30
|
+
// Keys live in AP_HOME by default (the pod host cannot read them);
|
|
31
|
+
// --keys pod stores them in pod state instead, so several devices can
|
|
32
|
+
// sign as the same actor without copying files.
|
|
33
|
+
//
|
|
34
|
+
// fedipod start start the agent (UI + API on https://localhost:8030/
|
|
35
|
+
// and http://<handle>.localhost:8030/ — one origin per
|
|
36
|
+
// identity, so two agents stop sharing one login).
|
|
37
|
+
// Prints both URLs; --open also opens a browser.
|
|
38
|
+
// --name "Your Name" sets the display name other
|
|
39
|
+
// servers show, and republishes the actor
|
|
40
|
+
// ('run' is kept as an alias)
|
|
41
|
+
// fedipod stop stop the running agent (graceful: flush + lease release)
|
|
42
|
+
// fedipod status show the running agent's status
|
|
43
|
+
// fedipod state where the private half lives — your timeline, contacts,
|
|
44
|
+
// blocklist and notifications. `--to <container-url>`
|
|
45
|
+
// moves it to a pod on this machine, `--to pod` moves it
|
|
46
|
+
// back. Copies and verifies before repointing; the old
|
|
47
|
+
// copy is left behind. Stop the agent first.
|
|
48
|
+
// fedipod rebuild put back the posts a restored or replaced machine no
|
|
49
|
+
// longer knows about, from what the pod still serves.
|
|
50
|
+
// Adds only — a post this machine already has keeps its
|
|
51
|
+
// local facts. `--from-notes` also walks ap/notes/,
|
|
52
|
+
// which finds more and can bring back a post whose
|
|
53
|
+
// deletion the pod refused. The agent must be running.
|
|
54
|
+
// (the default identity) is whichever one you last STARTED. Every identity is
|
|
55
|
+
// profiles/<name>/, and the root records the last one
|
|
56
|
+
// used, so `--profile x start` today is what plain
|
|
57
|
+
// `start` gives you tomorrow. Nothing to configure.
|
|
58
|
+
// fedipod home which directory every identity on this machine lives
|
|
59
|
+
// in. `--to <dir>` moves the whole root, rewrites any
|
|
60
|
+
// privateRoot that pointed inside it, and refuses while
|
|
61
|
+
// an agent is answering. `--restructure` is the one-time
|
|
62
|
+
// move for a root from before every identity lived in
|
|
63
|
+
// profiles/: it takes the identity at the top level down
|
|
64
|
+
// into profiles/<its handle>/. Installs made before the
|
|
65
|
+
// 2026-07-30 rename keep ~/.activitypod until they run
|
|
66
|
+
// `--to`; new ones get ~/.fedipod.
|
|
67
|
+
// fedipod passwd set/change the UI password (REQUIRED before any
|
|
68
|
+
// non-loopback exposure — it turns the instant
|
|
69
|
+
// OAuth redirect into a real login form)
|
|
70
|
+
// fedipod tokens list client tokens; --revoke <prefix> / --revoke-all
|
|
71
|
+
// fedipod revoke-credential --email you@example.org
|
|
72
|
+
// kill this machine's pod credential server-side and
|
|
73
|
+
// delete it locally (the answer to a suspected leak)
|
|
74
|
+
// fedipod install-service start at boot + restart on crash
|
|
75
|
+
// (systemd --user on Linux, launchd on mac)
|
|
76
|
+
// fedipod uninstall-service remove that registration
|
|
77
|
+
|
|
78
|
+
import fs from 'node:fs';
|
|
79
|
+
import { localFetch } from '../lib/localapi.mjs';
|
|
80
|
+
import os from 'node:os';
|
|
81
|
+
import path from 'node:path';
|
|
82
|
+
import net from 'node:net';
|
|
83
|
+
import readline from 'node:readline';
|
|
84
|
+
import { spawn } from 'node:child_process';
|
|
85
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
86
|
+
import { apRoot, profilesDir, identityHomes, isLegacyRoot, CURRENT_ROOT, tildify, rootOf,
|
|
87
|
+
readRoot, writeRoot, defaultProfile, profileHome, rootHoldsIdentity, ROOT_FILE,
|
|
88
|
+
recordLastUsed, writeJsonAtomic } from '../lib/home.mjs';
|
|
89
|
+
import { insecureUrlReason } from '../lib/safefetch.mjs';
|
|
90
|
+
import { portFree, freePortFrom } from '../lib/ports.mjs';
|
|
91
|
+
|
|
92
|
+
const args = process.argv.slice(2);
|
|
93
|
+
const cmd = args[0];
|
|
94
|
+
const flag = (name, dflt) => {
|
|
95
|
+
const i = args.indexOf('--' + name);
|
|
96
|
+
return i >= 0 ? args[i + 1] : dflt;
|
|
97
|
+
};
|
|
98
|
+
const has = (name) => args.includes('--' + name);
|
|
99
|
+
// The port can also be given bare — `npm start 8081` reaches us as `up 8081`,
|
|
100
|
+
// because npm passes positionals through but eats `--port`. Bare form only for
|
|
101
|
+
// the start-style commands, where a lone number cannot mean anything else.
|
|
102
|
+
const barePort = () => {
|
|
103
|
+
if (!['up', 'start', 'run'].includes(cmd)) return null;
|
|
104
|
+
for (let i = 1; i < args.length; i++) {
|
|
105
|
+
if (/^\d+$/.test(args[i]) && !args[i - 1].startsWith('--')) return args[i];
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
};
|
|
109
|
+
const portFlag = () => flag('port', null) || barePort();
|
|
110
|
+
// One identity per home, and EVERY identity is `<root>/profiles/<name>/`. There
|
|
111
|
+
// is no privileged unnamed one: `root.json` names which you get when you do not
|
|
112
|
+
// say, and that is a pointer you can change rather than a directory you have to
|
|
113
|
+
// move a private key out of. lib/home.mjs decides the root.
|
|
114
|
+
//
|
|
115
|
+
// An explicit --home / AP_HOME still wins and is taken literally: you named a
|
|
116
|
+
// directory, so that directory is the identity, root.json unread. That is what
|
|
117
|
+
// `rootOf` has always documented for a custom home.
|
|
118
|
+
const PROFILE = flag('profile', process.env.AP_PROFILE || null);
|
|
119
|
+
const AP_ROOT = apRoot();
|
|
120
|
+
const PROFILES_DIR = profilesDir(AP_ROOT);
|
|
121
|
+
|
|
122
|
+
// `let`, because setup does not know which identity it is until it has asked for
|
|
123
|
+
// the handle — the home is named after it. Everything below reads HOME at call
|
|
124
|
+
// time, so reassigning it once, early, is enough; PORT is the exception and is
|
|
125
|
+
// recomputed with it.
|
|
126
|
+
let DEFAULT_ISSUE = null; // set when the pointer is unusable
|
|
127
|
+
let HOME = flag('home', process.env.AP_HOME || (() => {
|
|
128
|
+
if (PROFILE) return profileHome(AP_ROOT, PROFILE);
|
|
129
|
+
const d = defaultProfile(AP_ROOT);
|
|
130
|
+
if (typeof d === 'string') return profileHome(AP_ROOT, d);
|
|
131
|
+
DEFAULT_ISSUE = d?.missing
|
|
132
|
+
? `${ROOT_FILE} names "${d.missing}", which is not an identity here`
|
|
133
|
+
: 'there is more than one identity here and none is the default';
|
|
134
|
+
return profileHome(AP_ROOT, d?.missing || '');
|
|
135
|
+
})());
|
|
136
|
+
|
|
137
|
+
// The port chosen at setup is remembered, so `start`/`stop`/`status` need no
|
|
138
|
+
// flags afterwards. Precedence: --port (or a bare port number, `npm start
|
|
139
|
+
// 8081`) > AP_PORT > the recorded choice > 8030.
|
|
140
|
+
// The handle is remembered alongside it, for the named origin: the agent also
|
|
141
|
+
// answers at <handle>.localhost:<port>, and that has to work from the first
|
|
142
|
+
// request, before pod state has been read.
|
|
143
|
+
function recordedAgent() {
|
|
144
|
+
try { return JSON.parse(fs.readFileSync(path.join(HOME, 'agent.json'), 'utf8')) || {}; }
|
|
145
|
+
catch { return {}; }
|
|
146
|
+
}
|
|
147
|
+
function recordedPort() { return Number(recordedAgent().port) || null; }
|
|
148
|
+
function recordAgent(fields) {
|
|
149
|
+
try {
|
|
150
|
+
fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
151
|
+
const rec = { ...recordedAgent(), ...fields };
|
|
152
|
+
writeJsonAtomic(path.join(HOME, 'agent.json'), rec, { mode: 0o644 });
|
|
153
|
+
} catch { /* the flag still works, it just isn't remembered */ }
|
|
154
|
+
}
|
|
155
|
+
let PORT = Number(portFlag() || process.env.AP_PORT || recordedPort() || 8030);
|
|
156
|
+
|
|
157
|
+
// Setup is the one command that cannot know its home in advance: the identity is
|
|
158
|
+
// named after the handle, and the handle is the first thing it asks. Everything
|
|
159
|
+
// that reads HOME does so at call time, so pointing it at the right directory as
|
|
160
|
+
// soon as the name exists is enough — PORT is recomputed because it was read
|
|
161
|
+
// from the old home's agent.json.
|
|
162
|
+
function useProfile(name) {
|
|
163
|
+
HOME = flag('home', process.env.AP_HOME || profileHome(AP_ROOT, name));
|
|
164
|
+
PORT = Number(portFlag() || process.env.AP_PORT || recordedPort() || 8030);
|
|
165
|
+
DEFAULT_ISSUE = null;
|
|
166
|
+
return HOME;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A handle becomes a directory name, so it is checked before it is one. Same
|
|
170
|
+
// rule the admin API applies before creating an actor (admin.mjs) — without it
|
|
171
|
+
// a handle containing a slash or `..` climbs out of profiles/.
|
|
172
|
+
const HANDLE_RE = /^[a-z0-9][a-z0-9_-]{0,30}$/;
|
|
173
|
+
function requireHandle(handle) {
|
|
174
|
+
if (HANDLE_RE.test(handle)) return handle;
|
|
175
|
+
console.error(`"${handle}" cannot be a handle: letters, digits, hyphens and underscores,`);
|
|
176
|
+
console.error('starting with a letter or digit, at most 31 characters.');
|
|
177
|
+
process.exit(2);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Commands that act on an identity need one decided. Says which of the three
|
|
181
|
+
// ways it failed, because "no identity" and "which identity" are different
|
|
182
|
+
// problems with different fixes.
|
|
183
|
+
function requireIdentity() {
|
|
184
|
+
// An explicit AP_HOME / --home is an explicit identity directory. Nothing
|
|
185
|
+
// about the machine's root applies to it — including whether that root has
|
|
186
|
+
// been restructured, which is somebody else's install's problem.
|
|
187
|
+
if (process.env.AP_HOME || flag('home')) return;
|
|
188
|
+
if (rootHoldsIdentity(AP_ROOT)) {
|
|
189
|
+
console.error(`${tildify(AP_ROOT)} still keeps an identity at its top level.`);
|
|
190
|
+
console.error('Every identity lives in profiles/<name>/ now. Move this one down with:\n');
|
|
191
|
+
console.error(` ${process.argv[1]} home --restructure\n`);
|
|
192
|
+
process.exit(2);
|
|
193
|
+
}
|
|
194
|
+
if (!DEFAULT_ISSUE) return;
|
|
195
|
+
const homes = identityHomes(AP_ROOT).filter(h => fs.existsSync(path.join(h.dir, 'credential.json')));
|
|
196
|
+
console.error(DEFAULT_ISSUE + '.');
|
|
197
|
+
if (homes.length) {
|
|
198
|
+
console.error(`\n ${process.argv[1]} --profile <name> start\n`);
|
|
199
|
+
console.error('Whichever you start is remembered, so plain commands mean that one afterwards.');
|
|
200
|
+
console.error(`here: ${homes.map(h => h.name).join(', ')}`);
|
|
201
|
+
} else {
|
|
202
|
+
console.error(`\nThere are no identities yet — ${process.argv[1]} setup`);
|
|
203
|
+
}
|
|
204
|
+
process.exit(2);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function askHidden(prompt) {
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
210
|
+
const onData = (c) => { if (String(c) !== '\n' && String(c) !== '\r') readline.moveCursor(process.stdout, -1, 0), process.stdout.write('*'); };
|
|
211
|
+
process.stdin.on('data', onData);
|
|
212
|
+
rl.question(prompt, (answer) => { process.stdin.off('data', onData); rl.close(); process.stdout.write('\n'); resolve(answer); });
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Plain prompt with a default: Enter accepts it. Non-interactive runs
|
|
217
|
+
// (scripts, CI) take the default silently, so flags remain sufficient.
|
|
218
|
+
let sharedRl = null; // one interface: a new one per
|
|
219
|
+
function ask(prompt, dflt = '') { // question would drop buffered input
|
|
220
|
+
if (!process.stdin.isTTY) return Promise.resolve(dflt);
|
|
221
|
+
sharedRl ||= readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
sharedRl.question(dflt ? `${prompt} [${dflt}]: ` : `${prompt}: `, (answer) => {
|
|
224
|
+
resolve(String(answer).trim() || dflt);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function endAsking() { sharedRl?.close(); sharedRl = null; }
|
|
229
|
+
|
|
230
|
+
// "Occupied" means "cannot be bound", not "does not answer HTTP": something
|
|
231
|
+
// holding a port without speaking HTTP reads as free to a GET, and then the
|
|
232
|
+
// agent dies on EADDRINUSE.
|
|
233
|
+
|
|
234
|
+
// The first port from `first` upward that binds. Walking always ends in one,
|
|
235
|
+
// so this is a step rather than a condition.
|
|
236
|
+
|
|
237
|
+
// Is anything at all on this port? For the operations that MOVE data, "it did
|
|
238
|
+
// not answer as one of ours" is not the same as "nothing is there": an agent
|
|
239
|
+
// started with AP_GATE_TOKEN answers 401 to an un-tokened /status, so agentOn
|
|
240
|
+
// reads a perfectly live agent as stopped — and a sweep that trusted it would
|
|
241
|
+
// copy the state out from under one, which the next write then overwrites.
|
|
242
|
+
// Bind to find out, and fail closed.
|
|
243
|
+
async function somethingOn(port) {
|
|
244
|
+
const mine = await agentOn(port);
|
|
245
|
+
if (mine) return 'running';
|
|
246
|
+
return (await portFree(port)) ? null : 'something is on the port and did not answer as ours';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Whatever is on the port — is it one of ours?
|
|
250
|
+
async function agentOn(port) {
|
|
251
|
+
try {
|
|
252
|
+
const res = await localFetch(HOME, port, `/status`, { signal: AbortSignal.timeout(2000) });
|
|
253
|
+
const body = await res.json();
|
|
254
|
+
return typeof body?.configured === 'boolean' ? body : null;
|
|
255
|
+
} catch { return null; }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const isInside = (root, p) => {
|
|
259
|
+
const rel = path.relative(root, p);
|
|
260
|
+
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
function openBrowser(url) {
|
|
264
|
+
try {
|
|
265
|
+
const win = process.platform === 'win32';
|
|
266
|
+
const cmd = win ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
267
|
+
const child = spawn(cmd, win ? ['/c', 'start', '', url] : [url], { detached: true, stdio: 'ignore' });
|
|
268
|
+
// A box with no opener at all (a server, a bare container) emits this
|
|
269
|
+
// asynchronously, where the try/catch cannot reach it — and an unhandled
|
|
270
|
+
// 'error' event on a child process ends the agent.
|
|
271
|
+
child.on('error', () => {});
|
|
272
|
+
child.unref();
|
|
273
|
+
} catch { /* best-effort — the URL is printed anyway */ }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// A one-shot command holds the lease only for its own duration: exiting without
|
|
277
|
+
// releasing it leaves the next `start` as a read-only viewer until the lease
|
|
278
|
+
// expires, which is 5 minutes of doing nothing.
|
|
279
|
+
async function finish(agent, code = 0) {
|
|
280
|
+
await agent.lease?.release().catch(() => {});
|
|
281
|
+
await agent.store?.flush?.().catch(() => {});
|
|
282
|
+
process.exit(code);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Anything that decides the identity. Given even one of these, setup stays on
|
|
286
|
+
// the command line exactly as it always did — scripts, CI and the tarball's
|
|
287
|
+
// unpack-and-go line depend on that. At a terminal with none of them, setup
|
|
288
|
+
// asks the two things it needs to open a browser and asks the rest there.
|
|
289
|
+
const IDENTITY_FLAGS = ['new-account', 'pod', 'issuer', 'email', 'name', 'pod-name',
|
|
290
|
+
'group', 'approve-joins', 'summary', 'icon', 'root', 'keys', 'rotate-key'];
|
|
291
|
+
|
|
292
|
+
// Refuse before anything is asked, let alone typed: setup used to overwrite
|
|
293
|
+
// credential.json in place, and a minted credential is only shown once — so
|
|
294
|
+
// the identity it belonged to could not be recovered afterwards.
|
|
295
|
+
function refuseExistingIdentity() {
|
|
296
|
+
const credPath = path.join(HOME, 'credential.json');
|
|
297
|
+
if (!fs.existsSync(credPath) || has('force')) return;
|
|
298
|
+
let held = '(unreadable)';
|
|
299
|
+
try { held = JSON.parse(fs.readFileSync(credPath, 'utf8')).remotePod; } catch {}
|
|
300
|
+
console.error(`${HOME} already holds an identity: ${held}`);
|
|
301
|
+
console.error('For another identity: fedipod setup --profile <name>');
|
|
302
|
+
console.error('To list what exists: fedipod profiles');
|
|
303
|
+
console.error('To replace this one: add --force (the old credential is lost)');
|
|
304
|
+
console.error('');
|
|
305
|
+
console.error('If a setup died half-way, do NOT re-run it — the credential it already');
|
|
306
|
+
console.error('minted cannot be minted twice. Run `fedipod start` and');
|
|
307
|
+
console.error('finish at /admin/setup/ in the browser.');
|
|
308
|
+
process.exit(2);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Ask the handle (permanent, and it names the origin) and the port, start
|
|
312
|
+
// serving, and hand over to the page at /admin/setup/. Nothing is created here: the
|
|
313
|
+
// agent's own POST /setup does all of it, so a closed tab cannot lose a
|
|
314
|
+
// credential that only exists in an HTTP response.
|
|
315
|
+
async function runBrowserSetup() {
|
|
316
|
+
const handle = flag('handle') || await ask('handle (the name in your address; permanent)');
|
|
317
|
+
if (!handle) { console.error('a handle is required'); process.exit(2); }
|
|
318
|
+
// The handle names the home, so nothing can be decided before it — including
|
|
319
|
+
// which identity would be overwritten, and which port was remembered.
|
|
320
|
+
requireHandle(handle);
|
|
321
|
+
useProfile(PROFILE || handle);
|
|
322
|
+
refuseExistingIdentity();
|
|
323
|
+
const port = portFlag() ? PORT : (Number(await ask('port', String(PORT))) || PORT);
|
|
324
|
+
endAsking();
|
|
325
|
+
|
|
326
|
+
// Recorded before the server starts, so `stop`/`status` work while the
|
|
327
|
+
// browser flow is still open — it used to be written only after the mint.
|
|
328
|
+
recordAgent({ port, handle });
|
|
329
|
+
|
|
330
|
+
const { Agent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
331
|
+
const { startAdmin } = await import(new URL('../lib/admin.mjs', import.meta.url));
|
|
332
|
+
const { hostLabel } = await import(new URL('../lib/guard.mjs', import.meta.url));
|
|
333
|
+
const agent = new Agent({ home: HOME, log: (...a) => console.log('[ap]', ...a) });
|
|
334
|
+
startAdmin({
|
|
335
|
+
port, handle, agent,
|
|
336
|
+
gateToken: process.env.AP_GATE_TOKEN || '',
|
|
337
|
+
log: (...a) => console.log('[ap]', ...a),
|
|
338
|
+
});
|
|
339
|
+
const shutdown = () => {
|
|
340
|
+
setTimeout(() => process.exit(0), 1500).unref();
|
|
341
|
+
try { fs.rmSync(path.join(HOME, 'agent.pid'), { force: true }); } catch {}
|
|
342
|
+
Promise.allSettled([agent.store.flush(), agent.lease?.release()]).finally(() => process.exit(0));
|
|
343
|
+
};
|
|
344
|
+
process.on('SIGINT', shutdown);
|
|
345
|
+
process.on('SIGTERM', shutdown);
|
|
346
|
+
|
|
347
|
+
const label = hostLabel(handle);
|
|
348
|
+
const named = label ? `https://${label}.localhost:${port}/` : null;
|
|
349
|
+
const plain = `https://localhost:${port}/`;
|
|
350
|
+
const pad = Math.max(named?.length || 0, plain.length);
|
|
351
|
+
console.log('');
|
|
352
|
+
if (named) {
|
|
353
|
+
console.log(` ${named.padEnd(pad)} <- opening this`);
|
|
354
|
+
console.log(` ${plain.padEnd(pad)} <- the same agent, if your browser cannot find that name`);
|
|
355
|
+
} else {
|
|
356
|
+
console.log(` ${plain}`);
|
|
357
|
+
}
|
|
358
|
+
console.log('\nsetup continues in the browser — Ctrl-C to stop\n');
|
|
359
|
+
openBrowser(named || plain);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// `npm start`. One command that does the obvious thing: find a port that
|
|
363
|
+
// binds, put the agent behind it, and open the browser where there is
|
|
364
|
+
// something to do — the setup form when there is no identity yet, the client
|
|
365
|
+
// when there is. Detached, because you asked for an agent, not a terminal
|
|
366
|
+
// that is now busy: it logs to AP_HOME/agent.log and `stop` finds it by pidfile.
|
|
367
|
+
if (cmd === 'up') {
|
|
368
|
+
// A signup page may have arranged an identity already: the installer records
|
|
369
|
+
// its parameters in first-run.json beside this script's package, and the
|
|
370
|
+
// carry-over outranks the last-used identity — whether this machine is fresh
|
|
371
|
+
// or already full of accounts, `npm start` right after the installer brings
|
|
372
|
+
// up the identity the signup arranged, beside any that exist. An explicitly
|
|
373
|
+
// named profile or home still means exactly what it says.
|
|
374
|
+
const firstRunFile = process.env.AP_FIRST_RUN
|
|
375
|
+
|| new URL('../first-run.json', import.meta.url).pathname;
|
|
376
|
+
const explicitHome = !!(PROFILE || flag('home', null) || process.env.AP_HOME);
|
|
377
|
+
let firstRun = null;
|
|
378
|
+
if (!explicitHome) {
|
|
379
|
+
try { firstRun = JSON.parse(fs.readFileSync(firstRunFile, 'utf8')); } catch { /* none */ }
|
|
380
|
+
}
|
|
381
|
+
if (firstRun?.handle) {
|
|
382
|
+
const carried = String(firstRun.handle);
|
|
383
|
+
requireHandle(carried);
|
|
384
|
+
if (fs.existsSync(path.join(profileHome(AP_ROOT, carried), 'credential.json'))) {
|
|
385
|
+
console.error(`the signup carried the name "${carried}", but an identity with that name already exists here.`);
|
|
386
|
+
console.error('To attach the existing identity instead, use the signup page\'s Manage → Attach —');
|
|
387
|
+
console.error(`then remove ${firstRunFile} and run this again.`);
|
|
388
|
+
process.exit(2);
|
|
389
|
+
}
|
|
390
|
+
useProfile(carried);
|
|
391
|
+
process.env.AP_FIRST_RUN = firstRunFile;
|
|
392
|
+
} else if (DEFAULT_ISSUE && !identityHomes(AP_ROOT).some(h => fs.existsSync(path.join(h.dir, 'credential.json')))) {
|
|
393
|
+
// A fresh machine has no identity, and an identity is named after its
|
|
394
|
+
// handle, so there is no home to start in until that is asked. One
|
|
395
|
+
// question, the same one `setup` opens with — `npm start` stays the
|
|
396
|
+
// single command it was.
|
|
397
|
+
if (!process.stdin.isTTY) {
|
|
398
|
+
console.error('no identities yet — fedipod setup');
|
|
399
|
+
process.exit(2);
|
|
400
|
+
}
|
|
401
|
+
const first = await ask('handle (the name in your address; permanent)');
|
|
402
|
+
endAsking();
|
|
403
|
+
if (!first) { console.error('a handle is required'); process.exit(2); }
|
|
404
|
+
requireHandle(first);
|
|
405
|
+
useProfile(first);
|
|
406
|
+
} else {
|
|
407
|
+
requireIdentity();
|
|
408
|
+
}
|
|
409
|
+
if (rootOf(HOME) === AP_ROOT) recordLastUsed(AP_ROOT, path.basename(HOME));
|
|
410
|
+
const preferred = Number(portFlag() || process.env.AP_PORT || recordedPort() || 8030);
|
|
411
|
+
const configured = fs.existsSync(path.join(HOME, 'credential.json'));
|
|
412
|
+
const { hostLabel } = await import(new URL('../lib/guard.mjs', import.meta.url));
|
|
413
|
+
|
|
414
|
+
let port = preferred;
|
|
415
|
+
let already = null;
|
|
416
|
+
if (!await portFree(preferred)) {
|
|
417
|
+
already = await agentOn(preferred);
|
|
418
|
+
// Ours already — nothing to start. The directory door yields to a real
|
|
419
|
+
// owner of its port. Anything else is simply not this port; walking on is
|
|
420
|
+
// what "occupied" has always meant here.
|
|
421
|
+
if (!already) {
|
|
422
|
+
const { yieldDirectory } = await import(new URL('../lib/directory.mjs', import.meta.url));
|
|
423
|
+
if (!await yieldDirectory(preferred, { portFree })) {
|
|
424
|
+
port = await freePortFrom(preferred + 1);
|
|
425
|
+
// The shared helper returns null; the message is this command's to write.
|
|
426
|
+
if (port == null) throw new Error(`no free port between ${preferred + 1} and ${preferred + 51}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Both branches take the named origin: setup at the shared one would file the
|
|
432
|
+
// first login under localhost:<port>, and the identity is stuck with it.
|
|
433
|
+
// The profile name is the handle — that is what naming identities after them
|
|
434
|
+
// bought — so the named origin works on the very first run, before anything
|
|
435
|
+
// has been recorded.
|
|
436
|
+
const label = hostLabel(recordedAgent().handle || path.basename(HOME));
|
|
437
|
+
// One listener, one scheme: the port the agent was given is the port you
|
|
438
|
+
// browse, over https.
|
|
439
|
+
const originNow = () => `https://${label ? label + '.' : ''}localhost:${port}`;
|
|
440
|
+
|
|
441
|
+
if (already) {
|
|
442
|
+
console.log(`already running on port ${port}`);
|
|
443
|
+
} else {
|
|
444
|
+
// The handle too, not just the port: the agent seeds its allowed hosts
|
|
445
|
+
// from agent.json, and without it the named origin this command is about
|
|
446
|
+
// to open would be refused on the very first run.
|
|
447
|
+
recordAgent(recordedAgent().handle ? { port } : { port, handle: path.basename(HOME) });
|
|
448
|
+
const child = spawn(process.execPath, [new URL('../run-agent.mjs', import.meta.url).pathname], {
|
|
449
|
+
detached: true, stdio: 'ignore',
|
|
450
|
+
env: { ...process.env, AP_HOME: HOME, AP_PORT: String(port) },
|
|
451
|
+
});
|
|
452
|
+
child.on('error', (e) => { console.error(`could not start the agent: ${e.message}`); process.exit(1); });
|
|
453
|
+
child.unref();
|
|
454
|
+
// Wait for it to answer before pointing a browser at it, or the first
|
|
455
|
+
// load races the listen and shows a connection error.
|
|
456
|
+
let up = false;
|
|
457
|
+
for (let i = 0; i < 60 && !up; i++) {
|
|
458
|
+
await new Promise(r => setTimeout(r, 250));
|
|
459
|
+
up = !!await agentOn(port);
|
|
460
|
+
}
|
|
461
|
+
if (!up) {
|
|
462
|
+
console.error(`the agent did not come up on port ${port} — see ${path.join(HOME, 'agent.log')}`);
|
|
463
|
+
process.exit(1);
|
|
464
|
+
}
|
|
465
|
+
console.log(`agent running on port ${port} (pid in ${path.join(HOME, 'agent.pid')})`);
|
|
466
|
+
if (port !== preferred) console.log(`port ${preferred} was taken, so it moved to ${port}`);
|
|
467
|
+
}
|
|
468
|
+
// The client pinned to this actor, not the bare root — root serves vendored
|
|
469
|
+
// Phanpy with no account bound, which reads as the wrong app entirely.
|
|
470
|
+
const url = configured ? `${originNow()}/admin/client/` : `${originNow()}/admin/setup/`;
|
|
471
|
+
console.log(`\n ${url}\n`);
|
|
472
|
+
console.log(configured ? 'stop it with: fedipod stop'
|
|
473
|
+
: 'setup continues in the browser. Stop it with: fedipod stop');
|
|
474
|
+
if (!has('no-open')) openBrowser(url);
|
|
475
|
+
process.exit(0);
|
|
476
|
+
} else if (cmd === 'setup' && PROFILE && (useProfile(PROFILE), refuseExistingIdentity(), false)) {
|
|
477
|
+
// Unreachable: --profile names the home before anything is asked, so the
|
|
478
|
+
// collision is knowable now. refuseExistingIdentity exits when it finds one;
|
|
479
|
+
// when it does not, this falls through to the real branches below.
|
|
480
|
+
} else if (cmd === 'setup' && process.stdin.isTTY && !has('cli')
|
|
481
|
+
&& !IDENTITY_FLAGS.some(f => args.includes('--' + f))) {
|
|
482
|
+
// No refusal yet: which identity this would overwrite is not knowable until
|
|
483
|
+
// the handle is asked, because the handle is what names the home.
|
|
484
|
+
await runBrowserSetup();
|
|
485
|
+
} else if (cmd === 'setup') {
|
|
486
|
+
const root = flag('root');
|
|
487
|
+
const kind = has('group') ? 'group' : 'person';
|
|
488
|
+
const approveJoins = has('group') && has('approve-joins');
|
|
489
|
+
const summary = flag('summary');
|
|
490
|
+
const icon = flag('icon');
|
|
491
|
+
let pod = flag('pod');
|
|
492
|
+
const interactive = process.stdin.isTTY;
|
|
493
|
+
|
|
494
|
+
// Everything that shapes your identity is asked for here, with defaults,
|
|
495
|
+
// because these are decisions — the pod name becomes half of your
|
|
496
|
+
// permanent address, and nobody should discover that after the fact.
|
|
497
|
+
// Flags skip the matching question, so scripted setup is unchanged.
|
|
498
|
+
let newAccount = has('new-account');
|
|
499
|
+
if (!newAccount && !pod) {
|
|
500
|
+
if (!interactive) {
|
|
501
|
+
console.error('need --email and --handle, plus either --pod <url> or --new-account');
|
|
502
|
+
process.exit(2);
|
|
503
|
+
}
|
|
504
|
+
const have = await ask('do you already have a Solid pod? (y/n)', 'n');
|
|
505
|
+
if (/^y/i.test(have)) {
|
|
506
|
+
// Either tuck the fediverse account into the pod they already have,
|
|
507
|
+
// or make a fresh pod on the same Solid account for it.
|
|
508
|
+
const where = await ask('store your fediverse account in that pod, or in a new pod? (existing/new)', 'existing');
|
|
509
|
+
if (/^n/i.test(where)) newAccount = true;
|
|
510
|
+
else pod = await ask('your pod address (e.g. https://you.solidcommunity.net/)');
|
|
511
|
+
} else {
|
|
512
|
+
newAccount = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (!newAccount && !pod) { console.error('no pod given'); process.exit(2); }
|
|
516
|
+
|
|
517
|
+
const issuer = flag('issuer') || await ask('Solid identity provider', 'https://solidcommunity.net');
|
|
518
|
+
// Before the password is asked for, let alone sent. The issuer is where it
|
|
519
|
+
// goes and the pod is where the credential it buys is used, so an http:
|
|
520
|
+
// address off this machine puts both in clear. Loopback is exempt: it never
|
|
521
|
+
// reaches a network interface, and a pod on this machine is an ordinary way
|
|
522
|
+
// to run this.
|
|
523
|
+
for (const [url, what] of [[issuer, 'identity provider address'], [pod, 'pod address']]) {
|
|
524
|
+
const bad = insecureUrlReason(url, what);
|
|
525
|
+
if (bad) { console.error(bad); process.exit(2); }
|
|
526
|
+
}
|
|
527
|
+
const email = flag('email') || await ask(`account email at ${new URL(issuer).host}`);
|
|
528
|
+
const handle = flag('handle') || await ask('handle (the name in your address; permanent)');
|
|
529
|
+
if (!email || !handle) {
|
|
530
|
+
console.error('an email and a handle are required');
|
|
531
|
+
process.exit(2);
|
|
532
|
+
}
|
|
533
|
+
// Before anything irreversible: the account creation and the mint are both
|
|
534
|
+
// one-way, and a credential is shown once. A home that cannot be decided has
|
|
535
|
+
// to be refused here, not after there is something to lose.
|
|
536
|
+
requireHandle(handle);
|
|
537
|
+
useProfile(PROFILE || handle);
|
|
538
|
+
refuseExistingIdentity();
|
|
539
|
+
const podName = newAccount
|
|
540
|
+
? (flag('pod-name') || await ask('pod name (this becomes the domain of your address)', handle))
|
|
541
|
+
: null;
|
|
542
|
+
const name = flag('name') || await ask('display name (shown above your address)', handle);
|
|
543
|
+
|
|
544
|
+
// A handle resolves through <host>/.well-known/webfinger, so it only works
|
|
545
|
+
// when the pod owns the root of its host. Whether a NEW pod gets its own
|
|
546
|
+
// subdomain is the server's call, so promise nothing here we cannot keep.
|
|
547
|
+
const { webfingerHost } = await import(new URL('../lib/wire.mjs', import.meta.url));
|
|
548
|
+
const issuerHost = new URL(issuer).host;
|
|
549
|
+
const wfHost = newAccount ? null : webfingerHost(pod);
|
|
550
|
+
// A person warned about an unresolvable handle is the one who suffers, so a
|
|
551
|
+
// warning is their call to accept. Nobody could ever find this group, and the
|
|
552
|
+
// people it would fail are not the operator reading the warning.
|
|
553
|
+
if (kind === 'group' && !newAccount && !wfHost) {
|
|
554
|
+
console.error(`${pod} is a path on ${new URL(pod).host}, not the root of its own host.`);
|
|
555
|
+
console.error('WebFinger is answered only at a host root, so nobody could find this group.');
|
|
556
|
+
console.error('Give the group a pod of its own: fedipod setup --group --new-account');
|
|
557
|
+
process.exit(2);
|
|
558
|
+
}
|
|
559
|
+
console.log(kind === 'group' ? '\nThe group will be:\n' : '\nYou will be:\n');
|
|
560
|
+
console.log(` ${name}`);
|
|
561
|
+
if (wfHost) {
|
|
562
|
+
console.log(` @${handle}@${wfHost}\n`);
|
|
563
|
+
} else if (newAccount) {
|
|
564
|
+
console.log(` @${handle}@${podName}.${issuerHost}\n`);
|
|
565
|
+
console.log(`— provided ${issuerHost} gives each pod its own subdomain. Some servers put`);
|
|
566
|
+
console.log(`pods at ${issuerHost}/${podName}/ instead, and a pod sharing a host cannot`);
|
|
567
|
+
console.log('answer WebFinger for an address. Setup checks which you got and says so');
|
|
568
|
+
console.log('before publishing anything.\n');
|
|
569
|
+
} else {
|
|
570
|
+
console.log(` @${handle}@${new URL(pod).host} — WILL NOT RESOLVE\n`);
|
|
571
|
+
console.log(`This pod is ${pod} — a path on ${new URL(pod).host}, not the root of its own`);
|
|
572
|
+
console.log('host. WebFinger is answered only at a host root, which this pod cannot');
|
|
573
|
+
console.log('write to, so other servers will not find you. Posting and reading still');
|
|
574
|
+
console.log('work; being discovered does not.\n');
|
|
575
|
+
}
|
|
576
|
+
console.log('The display name can be changed later; the handle and pod cannot.');
|
|
577
|
+
const go = await ask(newAccount
|
|
578
|
+
? (kind === 'group' ? 'create pod and group? (y/n)' : 'create pod and fediverse account? (y/n)')
|
|
579
|
+
: (kind === 'group' ? 'create group on this pod? (y/n)' : 'create fediverse account on this pod? (y/n)'), 'y');
|
|
580
|
+
if (!/^y/i.test(go)) { console.log('nothing was created'); process.exit(0); }
|
|
581
|
+
endAsking(); // hand the tty to the password prompt
|
|
582
|
+
|
|
583
|
+
const password = process.env.AP_PASSWORD || await askHidden(`password for ${email} at ${issuer}: `);
|
|
584
|
+
|
|
585
|
+
if (newAccount) {
|
|
586
|
+
const { createAccountWithPod } = await import(new URL('../lib/account.mjs', import.meta.url));
|
|
587
|
+
const made = await createAccountWithPod({ issuer, email, password, podName });
|
|
588
|
+
pod = made.pod;
|
|
589
|
+
console.log(`account + pod created: ${pod}`);
|
|
590
|
+
if (!webfingerHost(pod)) {
|
|
591
|
+
console.log(`\n${issuerHost} created the pod at a path rather than on its own subdomain,`);
|
|
592
|
+
console.log(`so @${handle}@\u2026 cannot be discovered by other fediverse servers.`);
|
|
593
|
+
const cont = kind === 'group' ? 'n' : (interactive ? await ask('continue anyway? (y/n)', 'n') : 'y');
|
|
594
|
+
endAsking();
|
|
595
|
+
if (!/^y/i.test(cont)) {
|
|
596
|
+
console.log('stopping \u2014 the pod exists, but no actor was published');
|
|
597
|
+
process.exit(0);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const { mintCredential } = await import(new URL('../lib/remote.mjs', import.meta.url));
|
|
603
|
+
// New credentials only: one already registered keeps the name it was minted
|
|
604
|
+
// under, so existing pods stay as they are unless they are set up again.
|
|
605
|
+
const credential = await mintCredential({ origin: issuer, email, password, name: 'fedipod' });
|
|
606
|
+
const rec = {
|
|
607
|
+
...credential,
|
|
608
|
+
remotePod: pod.endsWith('/') ? pod : pod + '/',
|
|
609
|
+
// The private half goes on THIS machine, exactly as the browser setup does
|
|
610
|
+
// (lib/setup.mjs). Omitting it here meant the two paths produced different
|
|
611
|
+
// installs from the same answers: the CLI put the timeline, contacts,
|
|
612
|
+
// blocklist and notifications on the pod — the layout the relay design
|
|
613
|
+
// exists to avoid, and the one that makes receiving a post cost pod writes.
|
|
614
|
+
// It also made provisioning write a tree it did not need, which is where a
|
|
615
|
+
// slow server showed it up.
|
|
616
|
+
privateRoot: flag('private-root')
|
|
617
|
+
|| pathToFileURL(path.join(HOME, 'private')).href + '/',
|
|
618
|
+
...(root ? { root } : {}),
|
|
619
|
+
...(flag('keys') === 'pod' ? { keysMode: 'pod' } : {}),
|
|
620
|
+
...(has('rotate-key') ? { rotateKeyOnce: true } : {}),
|
|
621
|
+
// What shape this install is. Both setup paths stamp it, so `upgrade` can
|
|
622
|
+
// tell an old install from a new one rather than inferring it.
|
|
623
|
+
layout: (await import(new URL('../lib/migrate.mjs', import.meta.url))).CURRENT_LAYOUT,
|
|
624
|
+
};
|
|
625
|
+
fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
626
|
+
writeJsonAtomic(path.join(HOME, 'credential.json'), rec);
|
|
627
|
+
// The one you just made is the one you are using.
|
|
628
|
+
if (rootOf(HOME) === AP_ROOT) recordLastUsed(AP_ROOT, path.basename(HOME));
|
|
629
|
+
recordAgent({ port: PORT, handle }); // later commands need no --port
|
|
630
|
+
console.log(`credential minted and saved to ${path.join(HOME, 'credential.json')}`);
|
|
631
|
+
|
|
632
|
+
const { Agent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
633
|
+
const agent = new Agent({ home: HOME, log: (...a) => console.log('[setup]', ...a) });
|
|
634
|
+
await agent.bootstrap({ handle, name, root, kind, approveJoins, summary, icon });
|
|
635
|
+
await agent.connect({ repair: false }); // publishProfile below is the publish
|
|
636
|
+
await agent.publisher.publishProfile();
|
|
637
|
+
await agent.store.flush();
|
|
638
|
+
const finalHost = webfingerHost(rec.remotePod);
|
|
639
|
+
const what = kind === 'group' ? 'group' : 'actor';
|
|
640
|
+
console.log(finalHost
|
|
641
|
+
? `${what} published: @${handle}@${finalHost}`
|
|
642
|
+
: `${what} published, but not reachable as a handle \u2014 ${rec.remotePod} is not a host root`);
|
|
643
|
+
|
|
644
|
+
// Straight into serving — setup ends with a working client in the browser.
|
|
645
|
+
const { startAdmin } = await import(new URL('../lib/admin.mjs', import.meta.url));
|
|
646
|
+
const { hostLabel } = await import(new URL('../lib/guard.mjs', import.meta.url));
|
|
647
|
+
startAdmin({ port: PORT, handle, gateToken: process.env.AP_GATE_TOKEN || '', agent, log: (...a) => console.log('[ap]', ...a) });
|
|
648
|
+
const shutdown = () => {
|
|
649
|
+
setTimeout(() => process.exit(0), 1500).unref(); // never hang a stop on a slow pod
|
|
650
|
+
try { fs.rmSync(path.join(HOME, 'agent.pid'), { force: true }); } catch {}
|
|
651
|
+
Promise.allSettled([agent.store.flush(), agent.lease?.release()]).finally(() => process.exit(0));
|
|
652
|
+
};
|
|
653
|
+
process.on('SIGINT', shutdown);
|
|
654
|
+
process.on('SIGTERM', shutdown);
|
|
655
|
+
// The named origin, and the instance to log the client into is that same
|
|
656
|
+
// origin. Opening the shared one and then telling you to log in there is how
|
|
657
|
+
// two identities end up sharing a browser storage bucket, and a client on the
|
|
658
|
+
// shared origin shows whichever account it happens to hold.
|
|
659
|
+
const label = hostLabel(handle);
|
|
660
|
+
const authority = `${label ? label + '.' : ''}localhost:${PORT}`;
|
|
661
|
+
const url = `http://${authority}/`;
|
|
662
|
+
if (kind === 'group') {
|
|
663
|
+
console.log(`group running on ${url} — see \`fedipod members\``);
|
|
664
|
+
} else {
|
|
665
|
+
console.log(`agent running — opening ${url} (log in with instance ${authority})`);
|
|
666
|
+
openBrowser(url);
|
|
667
|
+
}
|
|
668
|
+
} else if (cmd === 'start' || cmd === 'run') { // 'run' kept as an alias
|
|
669
|
+
requireIdentity();
|
|
670
|
+
// Starting one is what makes it the default — so `--profile group start`
|
|
671
|
+
// today is what a plain command means tomorrow. Recorded before the agent
|
|
672
|
+
// comes up, because a start that fails still expressed the intent.
|
|
673
|
+
if (rootOf(HOME) === AP_ROOT) recordLastUsed(AP_ROOT, path.basename(HOME));
|
|
674
|
+
// This flag is read only by setup; it silently did nothing here, while the
|
|
675
|
+
// key guard's own error message told people to use it.
|
|
676
|
+
if (has('rotate-key')) {
|
|
677
|
+
console.error('start does not rotate keys — use: fedipod rotate-key');
|
|
678
|
+
process.exit(2);
|
|
679
|
+
}
|
|
680
|
+
if (portFlag()) recordAgent({ port: PORT }); // `start --port N` (or bare N) moves it for good
|
|
681
|
+
|
|
682
|
+
// Something already on the port? Offer to take it over rather than dying
|
|
683
|
+
// with "address in use" and leaving the user to hunt the process down.
|
|
684
|
+
const answering = await localFetch(HOME, PORT, `/status`)
|
|
685
|
+
.then(r => r.status).catch(() => null);
|
|
686
|
+
if (answering !== null) {
|
|
687
|
+
const pidFile = path.join(HOME, 'agent.pid');
|
|
688
|
+
let pid = null;
|
|
689
|
+
try { pid = Number(fs.readFileSync(pidFile, 'utf8').trim()) || null; } catch {}
|
|
690
|
+
const who = pid ? `pid ${pid}` : 'started elsewhere';
|
|
691
|
+
if (!has('replace') && !process.stdin.isTTY) {
|
|
692
|
+
console.error(`an agent is already running on port ${PORT} (${who}).`);
|
|
693
|
+
console.error('Stop it with `fedipod stop`, or start this one with --replace.');
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
const ans = has('replace')
|
|
697
|
+
? 'y'
|
|
698
|
+
: await ask(`an agent is already running on port ${PORT} (${who}) — stop it and start this one? (y/n)`, 'y');
|
|
699
|
+
endAsking();
|
|
700
|
+
if (!/^y/i.test(ans)) { console.log('left the running agent alone'); process.exit(0); }
|
|
701
|
+
|
|
702
|
+
await localFetch(HOME, PORT, `/shutdown`, { method: 'POST' }).catch(() => {});
|
|
703
|
+
if (pid) { try { process.kill(pid, 'SIGTERM'); } catch {} }
|
|
704
|
+
const freed = await (async () => { // give it a few seconds
|
|
705
|
+
for (let i = 0; i < 30; i++) {
|
|
706
|
+
await new Promise(r => setTimeout(r, 300));
|
|
707
|
+
const still = await localFetch(HOME, PORT, `/status`).then(() => true).catch(() => false);
|
|
708
|
+
if (!still) return true;
|
|
709
|
+
}
|
|
710
|
+
return false;
|
|
711
|
+
})();
|
|
712
|
+
if (!freed && pid) { try { process.kill(pid, 'SIGKILL'); } catch {} await new Promise(r => setTimeout(r, 500)); }
|
|
713
|
+
const clear = await localFetch(HOME, PORT, `/status`).then(() => false).catch(() => true);
|
|
714
|
+
if (!clear) {
|
|
715
|
+
console.error(`could not stop whatever is on port ${PORT}. Find it with: ss -tlnp | grep :${PORT}`);
|
|
716
|
+
process.exit(1);
|
|
717
|
+
}
|
|
718
|
+
console.log('previous agent stopped');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const { startAgent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
722
|
+
const startHandle = recordedAgent().handle || null;
|
|
723
|
+
await startAgent({
|
|
724
|
+
home: HOME, port: PORT, name: flag('name') || null,
|
|
725
|
+
takeover: has('takeover'), // claim a lease whose holder is gone
|
|
726
|
+
handle: startHandle, // the named origin, before pod state is read
|
|
727
|
+
});
|
|
728
|
+
{
|
|
729
|
+
const { hostLabel } = await import(new URL('../lib/guard.mjs', import.meta.url));
|
|
730
|
+
const label = hostLabel(startHandle);
|
|
731
|
+
const named = label ? `https://${label}.localhost:${PORT}/` : null;
|
|
732
|
+
const plain = `https://localhost:${PORT}/`;
|
|
733
|
+
// One origin per identity is the point of the named form: a browser keeps
|
|
734
|
+
// its storage per origin, so two agents stop sharing one Phanpy login.
|
|
735
|
+
if (named) {
|
|
736
|
+
const pad = Math.max(named.length, plain.length);
|
|
737
|
+
console.log(`\n ${named.padEnd(pad)} <- browse it here`);
|
|
738
|
+
console.log(` ${plain.padEnd(pad)} <- the same agent, if that name will not resolve\n`);
|
|
739
|
+
} else {
|
|
740
|
+
console.log(`\n ${plain}\n`);
|
|
741
|
+
}
|
|
742
|
+
// Printed, not opened. `start` is run by supervisors and on every restart;
|
|
743
|
+
// a window arriving unasked over whatever you were doing is not a feature.
|
|
744
|
+
// `setup` opens one because that is the whole point of `setup`.
|
|
745
|
+
if (has('open')) openBrowser(named || plain);
|
|
746
|
+
}
|
|
747
|
+
} else if (cmd === 'state' && (has('all') || has('drop-remote'))) {
|
|
748
|
+
// The root-wide half. `state --to` moves ONE identity, with the right AP_HOME
|
|
749
|
+
// set by hand; this runs over every identity under the root.
|
|
750
|
+
//
|
|
751
|
+
// Per identity, in its own process. HOME, PORT and the root are resolved at
|
|
752
|
+
// module load from the environment, so doing several in one process would
|
|
753
|
+
// mean re-deriving all of it — and a failure part way would be sharing state
|
|
754
|
+
// with the next one. A spawn per identity is a handful of processes for a
|
|
755
|
+
// one-shot migration, and each is exactly the command you could have typed.
|
|
756
|
+
const { execFile } = await import('node:child_process');
|
|
757
|
+
const { needsStateMove, pendingSteps } = await import(new URL('../lib/migrate.mjs', import.meta.url));
|
|
758
|
+
const { apUrls } = await import(new URL('../lib/wire.mjs', import.meta.url));
|
|
759
|
+
|
|
760
|
+
const homes = identityHomes(AP_ROOT);
|
|
761
|
+
if (!homes.length) {
|
|
762
|
+
console.log(`no identities under ${tildify(AP_ROOT)} — nothing to move`);
|
|
763
|
+
process.exit(0);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// The same refusal `--to` and `home --restructure` make, for the same reason,
|
|
767
|
+
// and it has to cover ALL of them: a running agent holds its state
|
|
768
|
+
// write-through in memory, so a copy taken underneath one is overwritten by
|
|
769
|
+
// its next write.
|
|
770
|
+
// Only when something is actually going to be written. A dry run reads
|
|
771
|
+
// credential files and prints what it found, and refusing to do THAT while
|
|
772
|
+
// the agents are up defeats the whole point of leading with the inventory:
|
|
773
|
+
// you would have to stop everything to find out whether you needed to.
|
|
774
|
+
if (has('apply')) {
|
|
775
|
+
const live = [];
|
|
776
|
+
for (const { name, dir } of homes) {
|
|
777
|
+
let port = null;
|
|
778
|
+
try { port = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')).port; } catch { continue; }
|
|
779
|
+
if (!port) continue;
|
|
780
|
+
const why = await somethingOn(port);
|
|
781
|
+
if (why) live.push(`${name} on ${port} (${why})`);
|
|
782
|
+
}
|
|
783
|
+
if (live.length) {
|
|
784
|
+
console.error(`still answering: ${live.join(', ')}`);
|
|
785
|
+
console.error('stop them first — a running agent would overwrite the copy with what it holds');
|
|
786
|
+
process.exit(2);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const rows = [];
|
|
791
|
+
for (const { name, dir } of homes) {
|
|
792
|
+
let cred = null;
|
|
793
|
+
try { cred = JSON.parse(fs.readFileSync(path.join(dir, 'credential.json'), 'utf8')); } catch { continue; }
|
|
794
|
+
rows.push({ name, dir, cred, pending: pendingSteps(cred) });
|
|
795
|
+
}
|
|
796
|
+
if (!rows.length) { console.log('no identities with a credential yet'); process.exit(0); }
|
|
797
|
+
|
|
798
|
+
if (has('drop-remote')) {
|
|
799
|
+
// Deliberately separate from the move, and second. While both copies exist
|
|
800
|
+
// the move is reversible; this is the step that ends that, so it is never
|
|
801
|
+
// something you get by accident.
|
|
802
|
+
const { RemotePod } = await import(new URL('../lib/remote.mjs', import.meta.url));
|
|
803
|
+
const { classifyRemoteState } = await import(new URL('../lib/migrate.mjs', import.meta.url));
|
|
804
|
+
const apply = has('apply');
|
|
805
|
+
for (const { name, dir, cred } of rows) {
|
|
806
|
+
if (needsStateMove(cred)) {
|
|
807
|
+
console.log(`${name}: still on the pod — run \`state --all --apply\` first`);
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const urls = apUrls(cred.remotePod, cred.root);
|
|
811
|
+
const remote = new RemotePod(cred, { log: () => {}, home: dir });
|
|
812
|
+
try { await remote.warmup(); } catch (e) { console.error(`${name}: ${e.message}`); continue; }
|
|
813
|
+
const children = (await remote.listContainer(urls.state)).map(c => c.url);
|
|
814
|
+
const { drop, keep } = classifyRemoteState(children, urls.state);
|
|
815
|
+
console.log(`${name}: ${drop.length} document(s) to remove, ${keep.length} kept`);
|
|
816
|
+
for (const k of keep) console.log(` keep ${k.name} — ${k.why}`);
|
|
817
|
+
for (const d of drop) {
|
|
818
|
+
if (!apply) { console.log(` would remove ${d.name}`); continue; }
|
|
819
|
+
try { await remote.delete(d.url); console.log(` removed ${d.name}`); }
|
|
820
|
+
catch (e) { console.error(` ${d.name}: ${e.message}`); }
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (!apply) console.log('\nThis was a dry run. Add --apply to remove them.');
|
|
824
|
+
process.exit(0);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const apply = has('apply');
|
|
828
|
+
console.log(apply ? 'Moving the private half onto this machine.\n'
|
|
829
|
+
: 'What a move would do. Nothing is written without --apply.\n');
|
|
830
|
+
let moved = 0;
|
|
831
|
+
for (const { name, dir, cred, pending } of rows) {
|
|
832
|
+
const home = cred.privateRoot ? tildify(cred.privateRoot)
|
|
833
|
+
: `${apUrls(cred.remotePod, cred.root).home} (ON THE POD)`;
|
|
834
|
+
if (!pending.length) { console.log(`${name}: already current — ${home}`); continue; }
|
|
835
|
+
const dest = pathToFileURL(path.join(dir, 'private')).href + '/';
|
|
836
|
+
if (!apply) {
|
|
837
|
+
console.log(`${name}: ${home}\n → would move to ${tildify(dest)}`);
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
const out = await new Promise((resolve) => {
|
|
841
|
+
execFile(process.execPath, [process.argv[1], 'state', '--to', dest],
|
|
842
|
+
{ env: { ...process.env, AP_HOME: dir, AP_PROFILE: '' } },
|
|
843
|
+
(err, stdout, stderr) => resolve({ ok: !err, text: String(stdout) + String(stderr) }));
|
|
844
|
+
});
|
|
845
|
+
console.log(`${name}: ${out.ok ? 'moved' : 'FAILED'}`);
|
|
846
|
+
for (const line of out.text.split('\n').filter(Boolean)) console.log(` ${line}`);
|
|
847
|
+
if (out.ok) moved++;
|
|
848
|
+
}
|
|
849
|
+
if (apply) {
|
|
850
|
+
console.log(`\n${moved} identit(ies) moved. The pod still holds the old copy —`);
|
|
851
|
+
console.log('`state --drop-remote` removes it once you are satisfied.');
|
|
852
|
+
} else {
|
|
853
|
+
console.log('\nRe-run with --apply to do it.');
|
|
854
|
+
}
|
|
855
|
+
process.exit(0);
|
|
856
|
+
} else if (cmd === 'upgrade') {
|
|
857
|
+
// One runner, every identity. The point is that being behind is a thing you
|
|
858
|
+
// can ASK about rather than something you find out from a pod bill.
|
|
859
|
+
const { pendingSteps, layoutOf, CURRENT_LAYOUT, isCurrent } =
|
|
860
|
+
await import(new URL('../lib/migrate.mjs', import.meta.url));
|
|
861
|
+
const homes = identityHomes(AP_ROOT);
|
|
862
|
+
const behind = [];
|
|
863
|
+
for (const { name, dir } of homes) {
|
|
864
|
+
let cred = null;
|
|
865
|
+
try { cred = JSON.parse(fs.readFileSync(path.join(dir, 'credential.json'), 'utf8')); } catch { continue; }
|
|
866
|
+
const pending = pendingSteps(cred);
|
|
867
|
+
console.log(`${name}: layout ${layoutOf(cred)} of ${CURRENT_LAYOUT}${pending.length ? '' : ' — current'}`);
|
|
868
|
+
for (const s of pending) console.log(` ${s.id}: ${s.what}\n (${s.why})`);
|
|
869
|
+
// Nothing to do and never stamped: an install that was already in the right
|
|
870
|
+
// shape. Record it, so `upgrade` stops asking and the agent stops warning.
|
|
871
|
+
if (isCurrent(cred) && layoutOf(cred) < CURRENT_LAYOUT) {
|
|
872
|
+
writeJsonAtomic(path.join(dir, 'credential.json'), { ...cred, layout: CURRENT_LAYOUT });
|
|
873
|
+
console.log(' stamped as current');
|
|
874
|
+
}
|
|
875
|
+
if (pending.length) behind.push(name);
|
|
876
|
+
}
|
|
877
|
+
if (!homes.length) console.log(`no identities under ${tildify(AP_ROOT)}`);
|
|
878
|
+
else if (behind.length) {
|
|
879
|
+
console.log(`\n${behind.length} identit(ies) behind: ${behind.join(', ')}`);
|
|
880
|
+
console.log('The only step is the state move. See what it would do:');
|
|
881
|
+
console.log(` ${path.basename(process.argv[1])} state --all`);
|
|
882
|
+
console.log('then re-run it with --apply.');
|
|
883
|
+
} else if (homes.length) console.log('\nEverything here is at the current layout.');
|
|
884
|
+
process.exit(0);
|
|
885
|
+
} else if (cmd === 'state') {
|
|
886
|
+
requireIdentity();
|
|
887
|
+
// Where the private half lives, and how to move it. Copy, verify, THEN
|
|
888
|
+
// repoint — a pointer moved on its own leaves the agent reading one
|
|
889
|
+
// container and writing another, which is the divergence this avoids.
|
|
890
|
+
// The old copy is left behind on purpose; delete it when you are satisfied.
|
|
891
|
+
const credPath = path.join(HOME, 'credential.json');
|
|
892
|
+
let cred;
|
|
893
|
+
try { cred = JSON.parse(fs.readFileSync(credPath, 'utf8')); }
|
|
894
|
+
catch { console.error(`no identity in ${HOME} — run setup first`); process.exit(2); }
|
|
895
|
+
|
|
896
|
+
const { apUrls } = await import(new URL('../lib/wire.mjs', import.meta.url));
|
|
897
|
+
const { Agent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
898
|
+
const where = (c) => (c.privateRoot ? tildify(c.privateRoot) : `${apUrls(c.remotePod, c.root).home}(on the pod)`);
|
|
899
|
+
|
|
900
|
+
const to = flag('to');
|
|
901
|
+
if (!to) {
|
|
902
|
+
console.log(`private data: ${where(cred)}`);
|
|
903
|
+
console.log(`public face: ${apUrls(cred.remotePod, cred.root).home}`);
|
|
904
|
+
console.log('\nTo move it: fedipod state --to ~/somewhere/private/');
|
|
905
|
+
console.log(' fedipod state --to <container-url>');
|
|
906
|
+
console.log(' fedipod state --to pod');
|
|
907
|
+
process.exit(0);
|
|
908
|
+
}
|
|
909
|
+
if (await localFetch(HOME, PORT, `/status`).then(() => true).catch(() => false)) {
|
|
910
|
+
console.error(`an agent is running on port ${PORT} — stop it first: fedipod stop`);
|
|
911
|
+
process.exit(1);
|
|
912
|
+
}
|
|
913
|
+
// A path or a URL. `state` prints the path form, so refusing it here would
|
|
914
|
+
// mean what the command shows you is not what it takes back — two chars before
|
|
915
|
+
// the colon, so a Windows drive letter is a path rather than a scheme.
|
|
916
|
+
let target = null;
|
|
917
|
+
if (to !== 'pod') {
|
|
918
|
+
const asPath = !/^[a-z][a-z0-9+.-]+:/i.test(to);
|
|
919
|
+
const raw = asPath
|
|
920
|
+
? pathToFileURL(path.resolve(to.replace(/^~(?=[/\\]|$)/, os.homedir()))).href
|
|
921
|
+
: to;
|
|
922
|
+
target = raw.endsWith('/') ? raw : raw + '/';
|
|
923
|
+
try { new URL(target); } catch { console.error(`"${to}" is not a container URL or a path`); process.exit(2); }
|
|
924
|
+
}
|
|
925
|
+
if ((cred.privateRoot || null) === target) { console.log(`already there: ${where(cred)}`); process.exit(0); }
|
|
926
|
+
// Before anything is built or sent, not after. This check used to sit at the
|
|
927
|
+
// very bottom, past the copy — so `--to http://nas.local/private/`, a typo
|
|
928
|
+
// for https or a plaintext box on the LAN, wrote every state document
|
|
929
|
+
// (masto-tokens.json included) and every RDF note to that host in the clear,
|
|
930
|
+
// and only then said the address was refused. The messages around it, which
|
|
931
|
+
// say nothing was repointed and the old copy was left where it was, were
|
|
932
|
+
// true and read as "nothing happened".
|
|
933
|
+
if (/^https?:/i.test(target || '')) {
|
|
934
|
+
const bad = insecureUrlReason(target, 'private-data address');
|
|
935
|
+
if (bad) { console.error(bad); process.exit(2); }
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const agent = new Agent({ home: HOME, log: (...a) => console.log('[state]', ...a) });
|
|
939
|
+
agent.urls = apUrls(cred.remotePod, cred.root);
|
|
940
|
+
// Only one of the two sides can be the pod, and moving between two local
|
|
941
|
+
// pods needs no credential at all — so do not spend a token grant on it.
|
|
942
|
+
if (!cred.privateRoot || !target) {
|
|
943
|
+
const { RemotePod } = await import(new URL('../lib/remote.mjs', import.meta.url));
|
|
944
|
+
agent.remote = new RemotePod(cred, { log: () => {}, home: HOME });
|
|
945
|
+
// A pod that is not there is the ordinary case for an identity nobody has
|
|
946
|
+
// run in a while, and it used to arrive as an unhandled rejection — 20
|
|
947
|
+
// lines of undici internals ending in ECONNREFUSED, with the reason on the
|
|
948
|
+
// last line. Say which pod and why, and stop.
|
|
949
|
+
try { await agent.remote.warmup(); }
|
|
950
|
+
catch (e) {
|
|
951
|
+
const why = e?.cause?.code === 'ECONNREFUSED' ? 'nothing is listening there' : (e.message || String(e));
|
|
952
|
+
console.error(`cannot reach the pod this identity keeps its private half on:`);
|
|
953
|
+
console.error(` ${cred.remotePod} — ${why}`);
|
|
954
|
+
console.error('Nothing was copied and nothing was repointed. Start the pod and try again.');
|
|
955
|
+
process.exit(1);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
const destCred = { ...cred, privateRoot: target };
|
|
959
|
+
const from = agent.privateUrls(cred);
|
|
960
|
+
const dest = agent.privateUrls(destCred);
|
|
961
|
+
console.log(`moving the private half\n from ${from.state.replace(/ap-state\/$/, '')}\n to ${dest.state.replace(/ap-state\/$/, '')}\n`);
|
|
962
|
+
|
|
963
|
+
const { copyPrivateHalf } = await import(new URL('../lib/migrate.mjs', import.meta.url));
|
|
964
|
+
let copied;
|
|
965
|
+
try {
|
|
966
|
+
copied = await copyPrivateHalf({
|
|
967
|
+
from: { state: agent.privateStorage(cred, 'state'), fediverse: agent.privateStorage(cred, 'fediverse') },
|
|
968
|
+
to: { state: agent.privateStorage(destCred, 'state'), fediverse: agent.privateStorage(destCred, 'fediverse') },
|
|
969
|
+
log: (...a) => console.log('[state]', ...a),
|
|
970
|
+
});
|
|
971
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
972
|
+
console.log(`copied ${copied.docs} state document(s)`);
|
|
973
|
+
console.log(`copied ${copied.notes} note(s)`);
|
|
974
|
+
|
|
975
|
+
// An empty source produces an empty destination, and every check above
|
|
976
|
+
// passes: nothing failed to land because nothing was sent. The command then
|
|
977
|
+
// repointed the credential and said it had moved your private data. Say what
|
|
978
|
+
// actually happened instead — an empty move is usually a wrong --from, and
|
|
979
|
+
// finding that out later means looking for a timeline that was never there.
|
|
980
|
+
if (copied.docs === 0 && copied.notes === 0) {
|
|
981
|
+
console.log('\nNOTHING WAS COPIED — the source held no state documents and no notes.');
|
|
982
|
+
console.log(` from: ${where(cred)}`);
|
|
983
|
+
console.log('The pointer is being moved anyway, which is right for a fresh identity');
|
|
984
|
+
console.log('and wrong if you expected a timeline here. Check the source if so.\n');
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (target) cred.privateRoot = target; else delete cred.privateRoot;
|
|
988
|
+
// Stamp what this install now IS, so `upgrade` stops naming it and the agent
|
|
989
|
+
// stops saying it is behind. Moving back onto the pod un-stamps it, which is
|
|
990
|
+
// honest rather than punitive: that is the old shape, and it should read as
|
|
991
|
+
// the old shape whoever chose it.
|
|
992
|
+
{
|
|
993
|
+
const { CURRENT_LAYOUT, isCurrent } = await import(new URL('../lib/migrate.mjs', import.meta.url));
|
|
994
|
+
if (isCurrent(cred)) cred.layout = CURRENT_LAYOUT; else delete cred.layout;
|
|
995
|
+
}
|
|
996
|
+
writeJsonAtomic(credPath, cred);
|
|
997
|
+
console.log(`\nprivate data now: ${where(cred)}`);
|
|
998
|
+
console.log('The old copy was left where it was — `state --drop-remote` removes it');
|
|
999
|
+
console.log('once you are satisfied, or delete it by hand.');
|
|
1000
|
+
process.exit(0);
|
|
1001
|
+
} else if (cmd === 'rotate-key') {
|
|
1002
|
+
requireIdentity();
|
|
1003
|
+
const { Agent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
1004
|
+
const agent = new Agent({ home: HOME, log: (...a) => console.log('[rotate-key]', ...a) });
|
|
1005
|
+
// A home whose keys.json is gone or truncated cannot connect() at all:
|
|
1006
|
+
// resolveKeys refuses to mint over a key the actor already publishes, which
|
|
1007
|
+
// is the right default — minting silently would invalidate every signature
|
|
1008
|
+
// the other device can still make. It did leave the advice that refusal
|
|
1009
|
+
// prints ("run fedipod rotate-key") a dead end, though, because this
|
|
1010
|
+
// command connects first and meets the same refusal. --force arms the
|
|
1011
|
+
// one-shot rotation in the credential so the connect can get past it.
|
|
1012
|
+
const forced = has('force');
|
|
1013
|
+
const rotateCredPath = path.join(HOME, 'credential.json');
|
|
1014
|
+
if (forced) {
|
|
1015
|
+
const cred = JSON.parse(fs.readFileSync(rotateCredPath, 'utf8'));
|
|
1016
|
+
writeJsonAtomic(rotateCredPath, { ...cred, rotateKeyOnce: true });
|
|
1017
|
+
console.log('--force: the replacement key is minted as the agent connects\n');
|
|
1018
|
+
}
|
|
1019
|
+
// Read-only until you say yes: connecting for real acquires the lease and
|
|
1020
|
+
// starts the whole active agent — a destructive inbox drain, a channel
|
|
1021
|
+
// subscription, ACL probes and a tag-feed sweep — before the prompt.
|
|
1022
|
+
if (!await agent.connect({ act: false })) {
|
|
1023
|
+
console.error('nothing to rotate — no configured agent in this AP_HOME');
|
|
1024
|
+
process.exit(2);
|
|
1025
|
+
}
|
|
1026
|
+
const cfg = agent.store.getConfig();
|
|
1027
|
+
console.log(`\nRotating the signing key for @${cfg.handle}@${new URL(cfg.remotePod).host}\n`);
|
|
1028
|
+
console.log(' · a new keypair replaces the one in this home');
|
|
1029
|
+
console.log(' · the actor document is republished so other servers learn it');
|
|
1030
|
+
console.log(' · ANY OTHER DEVICE holding the old key stops being able to sign\n');
|
|
1031
|
+
const ans = has('yes') ? 'y' : await ask('rotate now? (y/n)', 'n');
|
|
1032
|
+
endAsking();
|
|
1033
|
+
if (!/^y/i.test(ans)) {
|
|
1034
|
+
// --force arms the one-shot rotation before the prompt, so the preview
|
|
1035
|
+
// connect can read past the mint refusal. Declining has to disarm it, or
|
|
1036
|
+
// "key unchanged" would be a lie: the next ordinary start would rotate,
|
|
1037
|
+
// and nothing on that path republishes the actor.
|
|
1038
|
+
if (forced) {
|
|
1039
|
+
try {
|
|
1040
|
+
const { rotateKeyOnce, ...rest } = JSON.parse(fs.readFileSync(rotateCredPath, 'utf8'));
|
|
1041
|
+
writeJsonAtomic(rotateCredPath, rest);
|
|
1042
|
+
} catch { /* the credential moved under us; the flag goes with it */ }
|
|
1043
|
+
}
|
|
1044
|
+
console.log('key unchanged');
|
|
1045
|
+
process.exit(0);
|
|
1046
|
+
}
|
|
1047
|
+
await agent.connect(); // now it may act
|
|
1048
|
+
if (forced) {
|
|
1049
|
+
// connect() has already minted it; all that is left is telling the
|
|
1050
|
+
// fediverse. Calling rotateKey here would mint a second one for nothing.
|
|
1051
|
+
await agent.publisher.publishProfile();
|
|
1052
|
+
console.log('rotated and republished');
|
|
1053
|
+
} else {
|
|
1054
|
+
const r = await agent.rotateKey();
|
|
1055
|
+
console.log(r.changed ? 'rotated and republished' : 'no change — the key was already fresh');
|
|
1056
|
+
}
|
|
1057
|
+
await finish(agent);
|
|
1058
|
+
} else if (cmd === 'profiles') {
|
|
1059
|
+
// Local files only, plus a quick liveness probe: nothing here needs the pod.
|
|
1060
|
+
const homes = identityHomes(AP_ROOT);
|
|
1061
|
+
|
|
1062
|
+
const rows = [];
|
|
1063
|
+
for (const { name, dir } of homes) {
|
|
1064
|
+
let pod = null, port = null;
|
|
1065
|
+
try { pod = JSON.parse(fs.readFileSync(path.join(dir, 'credential.json'), 'utf8')).remotePod; } catch {}
|
|
1066
|
+
try { port = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')).port; } catch {}
|
|
1067
|
+
if (!pod && !port) continue; // not an identity, just a directory
|
|
1068
|
+
let live = null;
|
|
1069
|
+
if (port) {
|
|
1070
|
+
live = await localFetch(HOME, port, `/status`, { signal: AbortSignal.timeout(1500) })
|
|
1071
|
+
.then(r => r.json()).catch(() => null);
|
|
1072
|
+
}
|
|
1073
|
+
// The kind lives in pod state, so it is only knowable from the live probe —
|
|
1074
|
+
// a stopped identity honestly shows nothing rather than a guess.
|
|
1075
|
+
rows.push({ name, pod: pod ? new URL(pod).host : '(no credential)', port: port || '—',
|
|
1076
|
+
kind: live?.kind === 'group' ? 'group' : live ? 'person' : '—',
|
|
1077
|
+
state: live ? `${live.mode}${live.podRequests ? ` · ${live.podRequests.perMinuteNow}/min` : ''}` : 'not running' });
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Which one answers with no --profile. A property of the ROOT, not of any
|
|
1081
|
+
// identity — which is the whole point of it being a pointer.
|
|
1082
|
+
const theDefault = (() => { const d = defaultProfile(AP_ROOT); return typeof d === 'string' ? d : null; })();
|
|
1083
|
+
if (!rows.length) console.log('no identities yet — fedipod setup');
|
|
1084
|
+
else {
|
|
1085
|
+
const w = (k, min) => Math.max(min, ...rows.map(r => String(r[k]).length));
|
|
1086
|
+
const [wn, wp, wo, wk] = [w('name', 7), w('pod', 3), w('port', 4), w('kind', 4)];
|
|
1087
|
+
console.log(`${'PROFILE'.padEnd(wn)} ${'POD'.padEnd(wp)} ${'PORT'.padEnd(wo)} ${'KIND'.padEnd(wk)} STATE`);
|
|
1088
|
+
for (const r of rows) {
|
|
1089
|
+
console.log(`${r.name.padEnd(wn)} ${String(r.pod).padEnd(wp)} ${String(r.port).padEnd(wo)}`
|
|
1090
|
+
+ ` ${String(r.kind).padEnd(wk)} ${r.state}${r.name === theDefault ? ' (default)' : ''}`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
console.log(`\nIdentities under a custom AP_HOME are not listed — only ${AP_ROOT}`
|
|
1094
|
+
+ ' and its profiles/*.');
|
|
1095
|
+
process.exit(0);
|
|
1096
|
+
} else if (cmd === 'home' && has('restructure')) {
|
|
1097
|
+
// One-time: move the identity that lives AT the root down into
|
|
1098
|
+
// profiles/<name>/, so every identity is a named folder and none of them
|
|
1099
|
+
// contains the others. Its own files only — profiles/ stays where it is.
|
|
1100
|
+
if (process.env.AP_HOME || flag('home')) {
|
|
1101
|
+
console.error('AP_HOME / --home is set. That is an explicit identity directory, not the');
|
|
1102
|
+
console.error('root this restructures — unset it and run again.');
|
|
1103
|
+
process.exit(2);
|
|
1104
|
+
}
|
|
1105
|
+
if (!rootHoldsIdentity(AP_ROOT)) {
|
|
1106
|
+
console.log(`nothing to move — ${tildify(AP_ROOT)} keeps no identity at its top level`);
|
|
1107
|
+
const d = defaultProfile(AP_ROOT);
|
|
1108
|
+
if (typeof d === 'string') console.log(`default identity: ${d}`);
|
|
1109
|
+
process.exit(0);
|
|
1110
|
+
}
|
|
1111
|
+
// Its name is its handle, which agent.json records and pod state confirms.
|
|
1112
|
+
// Falling back to the credential's pod host keeps a half-set-up root movable.
|
|
1113
|
+
let name = null;
|
|
1114
|
+
try { name = JSON.parse(fs.readFileSync(path.join(AP_ROOT, 'agent.json'), 'utf8')).handle || null; } catch {}
|
|
1115
|
+
if (!name) {
|
|
1116
|
+
try {
|
|
1117
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(AP_ROOT, 'private/ap-state/config.json'), 'utf8'));
|
|
1118
|
+
name = cfg.handle || null;
|
|
1119
|
+
} catch {}
|
|
1120
|
+
}
|
|
1121
|
+
name = flag('name', name);
|
|
1122
|
+
if (!name) {
|
|
1123
|
+
console.error('cannot tell what this identity is called — no handle in agent.json or pod state.');
|
|
1124
|
+
console.error(`Name it: ${process.argv[1]} home --restructure --name <name>`);
|
|
1125
|
+
process.exit(2);
|
|
1126
|
+
}
|
|
1127
|
+
requireHandle(name);
|
|
1128
|
+
const dest = profileHome(AP_ROOT, name);
|
|
1129
|
+
if (fs.existsSync(dest) && fs.readdirSync(dest).length) {
|
|
1130
|
+
console.error(`${tildify(dest)} exists and is not empty — refusing to merge two identities`);
|
|
1131
|
+
console.error(`Give the moved one another name: ${process.argv[1]} home --restructure --name <name>`);
|
|
1132
|
+
process.exit(2);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// A running agent holds its pidfile and log by path. Same refusal `--to` makes.
|
|
1136
|
+
const live = [];
|
|
1137
|
+
for (const { name: n, dir } of [{ name, dir: AP_ROOT }, ...identityHomes(AP_ROOT)]) {
|
|
1138
|
+
let port = null;
|
|
1139
|
+
try { port = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')).port; } catch { continue; }
|
|
1140
|
+
if (!port) continue;
|
|
1141
|
+
// somethingOn, not agentOn: a gated agent answers 401 to an un-tokened
|
|
1142
|
+
// /status, and this moves its private key out from under it.
|
|
1143
|
+
const why = await somethingOn(port);
|
|
1144
|
+
if (why) live.push(`${n} on ${port} (${why})`);
|
|
1145
|
+
}
|
|
1146
|
+
if (live.length) {
|
|
1147
|
+
console.error(`still answering: ${live.join(', ')}`);
|
|
1148
|
+
console.error('stop them first — a move would strand the pidfile and log they hold');
|
|
1149
|
+
process.exit(2);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Its own files, named explicitly. Everything else at the root — profiles/,
|
|
1153
|
+
// and root.json once it exists — belongs to the root and stays.
|
|
1154
|
+
const MOVE = ['credential.json', 'keys.json', 'agent.json', 'agent.log', 'token.json',
|
|
1155
|
+
'backoff.json', 'private'];
|
|
1156
|
+
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
1157
|
+
const moved = [];
|
|
1158
|
+
for (const f of MOVE) {
|
|
1159
|
+
const from = path.join(AP_ROOT, f);
|
|
1160
|
+
if (!fs.existsSync(from)) continue;
|
|
1161
|
+
fs.renameSync(from, path.join(dest, f));
|
|
1162
|
+
moved.push(f);
|
|
1163
|
+
}
|
|
1164
|
+
// A pidfile names a process that was told to stop. Carrying it forward would
|
|
1165
|
+
// point `stop` at a pid nobody owns.
|
|
1166
|
+
fs.rmSync(path.join(AP_ROOT, 'agent.pid'), { force: true });
|
|
1167
|
+
console.log(`moved ${moved.length} item(s) → ${tildify(dest)}`);
|
|
1168
|
+
console.log(` ${moved.join(', ')}`);
|
|
1169
|
+
|
|
1170
|
+
// privateRoot is an absolute path; one that pointed at the root's private/
|
|
1171
|
+
// now points at nothing, and an agent finding an empty store reports itself
|
|
1172
|
+
// unconfigured rather than saying why.
|
|
1173
|
+
const credPath = path.join(dest, 'credential.json');
|
|
1174
|
+
try {
|
|
1175
|
+
const cred = JSON.parse(fs.readFileSync(credPath, 'utf8'));
|
|
1176
|
+
if (cred.privateRoot && !/^https?:/i.test(cred.privateRoot)) {
|
|
1177
|
+
const was = cred.privateRoot.startsWith('file:')
|
|
1178
|
+
? fileURLToPath(cred.privateRoot) : path.resolve(cred.privateRoot);
|
|
1179
|
+
if (isInside(AP_ROOT, was) && !isInside(dest, was)) {
|
|
1180
|
+
cred.privateRoot = pathToFileURL(path.join(dest, path.relative(AP_ROOT, was))).href + '/';
|
|
1181
|
+
writeJsonAtomic(credPath, cred);
|
|
1182
|
+
console.log(` private data now ${tildify(cred.privateRoot)}`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
} catch { /* no credential to repoint */ }
|
|
1186
|
+
|
|
1187
|
+
recordLastUsed(AP_ROOT, name);
|
|
1188
|
+
console.log(`\n${name} is what a plain command means now. Start another with`);
|
|
1189
|
+
console.log(`\`${path.basename(process.argv[1])} --profile <name> start\` and that becomes the one instead.`);
|
|
1190
|
+
process.exit(0);
|
|
1191
|
+
} else if (cmd === 'home') {
|
|
1192
|
+
// The root holds the credential and the signing keys of every identity on
|
|
1193
|
+
// this machine, so taking the post-rename name is a command you run, never
|
|
1194
|
+
// something an upgrade does behind you. Resolution is in lib/home.mjs.
|
|
1195
|
+
const to = flag('to');
|
|
1196
|
+
const overridden = !!(process.env.AP_HOME || flag('home'));
|
|
1197
|
+
|
|
1198
|
+
if (!to) {
|
|
1199
|
+
console.log(`\nroot: ${tildify(AP_ROOT)}${isLegacyRoot(AP_ROOT) ? ' (the name from before the rename)' : ''}`);
|
|
1200
|
+
console.log(`this home: ${tildify(HOME)}`);
|
|
1201
|
+
const d = defaultProfile(AP_ROOT);
|
|
1202
|
+
for (const { name, dir } of identityHomes(AP_ROOT)) {
|
|
1203
|
+
if (fs.existsSync(path.join(dir, 'credential.json'))) {
|
|
1204
|
+
console.log(` · ${name}${name === d ? ' (default)' : ''}`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (rootHoldsIdentity(AP_ROOT)) {
|
|
1208
|
+
console.log('\nThis root still keeps an identity at its top level, from before every');
|
|
1209
|
+
console.log('identity moved under profiles/. Move it down with:\n');
|
|
1210
|
+
console.log(` ${process.argv[1]} home --restructure\n`);
|
|
1211
|
+
}
|
|
1212
|
+
if (overridden) {
|
|
1213
|
+
console.log('\nAP_HOME / --home is set, so this run is not using the root above.');
|
|
1214
|
+
} else if (isLegacyRoot(AP_ROOT)) {
|
|
1215
|
+
console.log('\nTake the current name with:\n');
|
|
1216
|
+
console.log(` ${process.argv[1]} home --to ${path.join(os.homedir(), CURRENT_ROOT)}\n`);
|
|
1217
|
+
console.log('That moves the whole root — the default identity and every profile — and');
|
|
1218
|
+
console.log('refuses while any of them is answering.');
|
|
1219
|
+
}
|
|
1220
|
+
process.exit(0);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (overridden) {
|
|
1224
|
+
console.error('AP_HOME / --home is set. That is an explicit directory, not the root this');
|
|
1225
|
+
console.error('command moves — unset it and run again, or move the directory yourself.');
|
|
1226
|
+
process.exit(2);
|
|
1227
|
+
}
|
|
1228
|
+
const target = path.resolve(to.replace(/^~(?=[/\\]|$)/, os.homedir()));
|
|
1229
|
+
if (target === AP_ROOT) { console.log(`already there: ${AP_ROOT}`); process.exit(0); }
|
|
1230
|
+
if (!fs.existsSync(AP_ROOT)) { console.error(`nothing to move — ${AP_ROOT} does not exist`); process.exit(2); }
|
|
1231
|
+
if (fs.existsSync(target) && fs.readdirSync(target).length) {
|
|
1232
|
+
console.error(`${target} exists and is not empty — refusing to merge two roots`);
|
|
1233
|
+
process.exit(2);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// A running agent holds its pidfile and log by path; moving out from under it
|
|
1237
|
+
// strands both and leaves `stop` with nothing to find.
|
|
1238
|
+
const live = [];
|
|
1239
|
+
for (const { name, dir } of identityHomes(AP_ROOT)) {
|
|
1240
|
+
let port = null;
|
|
1241
|
+
try { port = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')).port; } catch { continue; }
|
|
1242
|
+
if (port && await agentOn(port)) live.push(`${name} on ${port}`);
|
|
1243
|
+
}
|
|
1244
|
+
if (live.length) {
|
|
1245
|
+
console.error(`still answering: ${live.join(', ')}`);
|
|
1246
|
+
console.error('stop them first — a move would strand the pidfile and log they hold');
|
|
1247
|
+
process.exit(2);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1251
|
+
try {
|
|
1252
|
+
fs.renameSync(AP_ROOT, target);
|
|
1253
|
+
} catch (e) {
|
|
1254
|
+
if (e.code !== 'EXDEV') throw e; // a different filesystem
|
|
1255
|
+
fs.cpSync(AP_ROOT, target, { recursive: true, preserveTimestamps: true });
|
|
1256
|
+
fs.rmSync(AP_ROOT, { recursive: true, force: true });
|
|
1257
|
+
}
|
|
1258
|
+
console.log(`moved ${AP_ROOT} → ${target}`);
|
|
1259
|
+
|
|
1260
|
+
// privateRoot is recorded as an absolute path. One that pointed inside the
|
|
1261
|
+
// root we just moved now points at nothing, and an agent finding an empty
|
|
1262
|
+
// store reports itself unconfigured rather than saying why.
|
|
1263
|
+
for (const { name, dir } of identityHomes(target)) {
|
|
1264
|
+
const credPath = path.join(dir, 'credential.json');
|
|
1265
|
+
let cred;
|
|
1266
|
+
try { cred = JSON.parse(fs.readFileSync(credPath, 'utf8')); } catch { continue; }
|
|
1267
|
+
if (!cred.privateRoot || /^https?:/i.test(cred.privateRoot)) continue; // a pod, not a directory
|
|
1268
|
+
const was = cred.privateRoot.startsWith('file:')
|
|
1269
|
+
? fileURLToPath(cred.privateRoot) : path.resolve(cred.privateRoot);
|
|
1270
|
+
if (!isInside(AP_ROOT, was)) continue; // somewhere else entirely
|
|
1271
|
+
const now = path.join(target, path.relative(AP_ROOT, was));
|
|
1272
|
+
cred.privateRoot = pathToFileURL(now).href + '/';
|
|
1273
|
+
writeJsonAtomic(credPath, cred);
|
|
1274
|
+
console.log(` · ${name}: private data now ${cred.privateRoot}`);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
console.log('\nIf you installed the service, its unit has the old path baked in as');
|
|
1278
|
+
console.log('Environment=AP_HOME — re-run install-service to update it.');
|
|
1279
|
+
process.exit(0);
|
|
1280
|
+
} else if (cmd === 'park' || cmd === 'revive') {
|
|
1281
|
+
// Park is quiesce plus a snapshot of the follow graph, because unfollowing is
|
|
1282
|
+
// what stops the traffic and also what destroys the record needed to come back.
|
|
1283
|
+
const { Agent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
1284
|
+
const agent = new Agent({ home: HOME, log: (...a) => console.log(`[${cmd}]`, ...a) });
|
|
1285
|
+
// Read-only until you say yes: connecting for real acquires the lease and
|
|
1286
|
+
// starts the whole active agent — a destructive inbox drain, a channel
|
|
1287
|
+
// subscription, ACL probes and a tag-feed sweep — before the prompt.
|
|
1288
|
+
if (!await agent.connect({ act: false })) {
|
|
1289
|
+
console.error(`nothing to ${cmd} — no configured, un-retired agent in this AP_HOME`);
|
|
1290
|
+
process.exit(2);
|
|
1291
|
+
}
|
|
1292
|
+
const cfg = agent.store.getConfig();
|
|
1293
|
+
const host = new URL(cfg.remotePod).host;
|
|
1294
|
+
|
|
1295
|
+
if (cmd === 'park') {
|
|
1296
|
+
const contacts = agent.store.getContacts();
|
|
1297
|
+
console.log(`\nParking @${cfg.handle}@${host}\n`);
|
|
1298
|
+
console.log(` · ${contacts.following.length} account(s) unfollowed, and remembered so revive can undo it`);
|
|
1299
|
+
console.log(' · the inbox is closed: deliveries are refused outright, not stored');
|
|
1300
|
+
console.log(` · ${contacts.followers.length} follower(s) keep following you — nothing is told you left`);
|
|
1301
|
+
console.log(' · the handle keeps resolving; posts and RDF stay where they are');
|
|
1302
|
+
console.log(' · a parked agent that gets started will not drain or poll\n');
|
|
1303
|
+
console.log('Quietest state short of retiring. Undo with: fedipod revive\n');
|
|
1304
|
+
const ans = has('yes') ? 'y' : await ask('park this actor? (y/n)', 'n');
|
|
1305
|
+
endAsking();
|
|
1306
|
+
if (!/^y/i.test(ans)) { console.log('nothing changed'); process.exit(0); }
|
|
1307
|
+
await agent.connect(); // now it may act
|
|
1308
|
+
const r = await agent.park();
|
|
1309
|
+
console.log(`parked: unfollowed ${r.unfollowed}/${r.following}, ${r.snapshot} remembered, inbox closed`);
|
|
1310
|
+
} else {
|
|
1311
|
+
const parked = agent.store.read('parked.json', null);
|
|
1312
|
+
console.log(`\nReviving @${cfg.handle}@${host}${parked ? ` (parked ${parked.parkedAt})` : ''}\n`);
|
|
1313
|
+
console.log(` · the inbox re-opens`);
|
|
1314
|
+
console.log(` · ${parked?.following?.length || 0} Follow(s) are re-sent — each needs the far side to accept\n`);
|
|
1315
|
+
const ans = has('yes') ? 'y' : await ask('revive this actor? (y/n)', 'y');
|
|
1316
|
+
endAsking();
|
|
1317
|
+
if (!/^y/i.test(ans)) { console.log('nothing changed'); process.exit(0); }
|
|
1318
|
+
await agent.connect(); // now it may act
|
|
1319
|
+
const r = await agent.revive();
|
|
1320
|
+
console.log(`revived: inbox open, ${r.refollowed}/${r.of} follow(s) re-sent`);
|
|
1321
|
+
}
|
|
1322
|
+
await finish(agent);
|
|
1323
|
+
} else if (cmd === 'retire') {
|
|
1324
|
+
// Without this an abandoned pod accepts fediverse deliveries forever into a
|
|
1325
|
+
// container nobody will ever drain, and no remote server can tell.
|
|
1326
|
+
const { Agent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
1327
|
+
const { resolveHandle } = await import(new URL('../lib/social.mjs', import.meta.url));
|
|
1328
|
+
const agent = new Agent({ home: HOME, log: (...a) => console.log('[retire]', ...a) });
|
|
1329
|
+
// Read-only until you say yes: connecting for real acquires the lease and
|
|
1330
|
+
// starts the whole active agent — a destructive inbox drain, a channel
|
|
1331
|
+
// subscription, ACL probes and a tag-feed sweep — before the prompt.
|
|
1332
|
+
if (!await agent.connect({ act: false })) {
|
|
1333
|
+
console.error('nothing to retire — no configured, un-retired agent in this AP_HOME');
|
|
1334
|
+
process.exit(2);
|
|
1335
|
+
}
|
|
1336
|
+
const cfg = agent.store.getConfig();
|
|
1337
|
+
const host = new URL(cfg.remotePod).host;
|
|
1338
|
+
const contacts = agent.store.getContacts();
|
|
1339
|
+
const followers = contacts.followers.length;
|
|
1340
|
+
const following = contacts.following.length;
|
|
1341
|
+
const moveTo = flag('move-to');
|
|
1342
|
+
const keep = has('keep-handle') || !!moveTo;
|
|
1343
|
+
|
|
1344
|
+
console.log(`\n${keep ? 'Standing down' : 'Retiring'} @${cfg.handle}@${host}\n`);
|
|
1345
|
+
if (moveTo) {
|
|
1346
|
+
console.log(` · a Move goes to ${followers} follower inbox(es); their servers migrate them to ${moveTo}`);
|
|
1347
|
+
console.log(` · the actor advertises movedTo, so the old handle resolves as a redirect`);
|
|
1348
|
+
} else if (keep) {
|
|
1349
|
+
console.log(' · the handle keeps resolving — webfinger, host-meta and the actor stay published');
|
|
1350
|
+
} else {
|
|
1351
|
+
console.log(` · a Delete goes to ${followers} follower inbox(es), telling those servers to drop the account`);
|
|
1352
|
+
console.log(' · the actor document is replaced with a Tombstone');
|
|
1353
|
+
console.log(' · this agent will refuse to start again for this pod');
|
|
1354
|
+
}
|
|
1355
|
+
if (keep) {
|
|
1356
|
+
console.log(` · ${following} account(s) get unfollowed — that is what stops posts arriving`);
|
|
1357
|
+
console.log(' · the inbox is closed, so anything else is refused rather than stored forever');
|
|
1358
|
+
}
|
|
1359
|
+
// revive() opens the inbox first and only then replays parked.json, which a
|
|
1360
|
+
// stand-down never wrote — so it is the right undo here, minus the re-follows.
|
|
1361
|
+
console.log(keep
|
|
1362
|
+
? '\nReversible: fedipod revive re-opens the inbox. Standing down keeps no snapshot\n'
|
|
1363
|
+
+ 'of the follow graph, unlike park, so following people again is on you.\n'
|
|
1364
|
+
: '\nYour posts and RDF stay on the pod; the identity does not come back.\n');
|
|
1365
|
+
|
|
1366
|
+
const ans = has('yes') ? 'y' : await ask(
|
|
1367
|
+
keep ? 'stand this actor down? (y/n)' : 'retire this actor? this cannot be undone (y/n)', 'n');
|
|
1368
|
+
endAsking();
|
|
1369
|
+
if (!/^y/i.test(ans)) { console.log('nothing changed'); process.exit(0); }
|
|
1370
|
+
await agent.connect(); // now it may act
|
|
1371
|
+
|
|
1372
|
+
if (moveTo) {
|
|
1373
|
+
// Accept either a full actor URL or @user@host.
|
|
1374
|
+
const target = /^https?:\/\//.test(moveTo) ? moveTo : await resolveHandle(agent, moveTo);
|
|
1375
|
+
const r = await agent.moveTo(target);
|
|
1376
|
+
console.log(`moved to ${r.target}: Move delivered to ${r.inboxes} inbox(es), unfollowed ${r.unfollowed}/${r.following}`);
|
|
1377
|
+
} else if (keep) {
|
|
1378
|
+
const r = await agent.park(); // same thing, and revivable
|
|
1379
|
+
console.log(`stood down ${r.quiescedAt}: unfollowed ${r.unfollowed}/${r.following}, inbox closed`);
|
|
1380
|
+
console.log('undo with: fedipod revive');
|
|
1381
|
+
} else {
|
|
1382
|
+
const r = await agent.publisher.retireActor();
|
|
1383
|
+
console.log(`retired ${r.deletedAt}: Delete delivered to ${r.inboxes} inbox(es)`);
|
|
1384
|
+
}
|
|
1385
|
+
await finish(agent);
|
|
1386
|
+
} else if (cmd === 'revoke-credential') {
|
|
1387
|
+
// The credential file cannot be protected from anything running as you —
|
|
1388
|
+
// so the answer to a suspected leak is to kill it server-side, fast.
|
|
1389
|
+
const { Agent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
1390
|
+
const { revokeCredentialViaAccount } = await import(new URL('../lib/remote.mjs', import.meta.url));
|
|
1391
|
+
const agent = new Agent({ home: HOME, log: () => {} });
|
|
1392
|
+
const cred = agent.readCredential();
|
|
1393
|
+
if (!cred) { console.error('no credential to revoke'); process.exit(2); }
|
|
1394
|
+
const email = flag('email');
|
|
1395
|
+
if (!email) { console.error('need --email <account email> (the account password is prompted)'); process.exit(2); }
|
|
1396
|
+
const password = process.env.AP_PASSWORD || await askHidden(`password for ${email} at ${cred.issuerOrigin}: `);
|
|
1397
|
+
const ok = await revokeCredentialViaAccount({
|
|
1398
|
+
origin: cred.issuerOrigin, email, password, resource: cred.resource,
|
|
1399
|
+
}).catch(e => { console.error(`revoke failed: ${e.message}`); return false; });
|
|
1400
|
+
if (ok) {
|
|
1401
|
+
fs.rmSync(path.join(HOME, 'credential.json'), { force: true });
|
|
1402
|
+
console.log('credential revoked server-side and deleted locally — run setup again to reconnect');
|
|
1403
|
+
} else {
|
|
1404
|
+
console.error('server did not confirm revocation — revoke it from the account dashboard');
|
|
1405
|
+
console.error(`(credential resource: ${cred.resource || 'unknown — dashboard only'})`);
|
|
1406
|
+
process.exit(1);
|
|
1407
|
+
}
|
|
1408
|
+
} else if (cmd === 'tokens') {
|
|
1409
|
+
const { Agent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
1410
|
+
const { RemotePod } = await import(new URL('../lib/remote.mjs', import.meta.url));
|
|
1411
|
+
const { apUrls } = await import(new URL('../lib/wire.mjs', import.meta.url));
|
|
1412
|
+
const agent = new Agent({ home: HOME, log: () => {} });
|
|
1413
|
+
const cred = agent.readCredential();
|
|
1414
|
+
if (!cred) { console.error('no credential — run setup first'); process.exit(2); }
|
|
1415
|
+
agent.remote = new RemotePod(cred);
|
|
1416
|
+
await agent.remote.warmup();
|
|
1417
|
+
agent.urls = apUrls(cred.remotePod, cred.root);
|
|
1418
|
+
agent.store.attach(agent.privateStorage(cred, 'state')); // honours privateRoot
|
|
1419
|
+
await agent.store.load();
|
|
1420
|
+
const recs = agent.store.read('masto-tokens.json', [])
|
|
1421
|
+
.map(r => (typeof r === 'string' ? { token: r, createdAt: null } : r));
|
|
1422
|
+
if (has('revoke-all')) {
|
|
1423
|
+
agent.store.write('masto-tokens.json', []);
|
|
1424
|
+
await agent.store.flush();
|
|
1425
|
+
console.log(`revoked ${recs.length} token(s) — every logged-in client must log in again`);
|
|
1426
|
+
} else if (flag('revoke')) {
|
|
1427
|
+
const prefix = flag('revoke');
|
|
1428
|
+
const kept = recs.filter(r => !r.token.startsWith(prefix));
|
|
1429
|
+
agent.store.write('masto-tokens.json', kept);
|
|
1430
|
+
await agent.store.flush();
|
|
1431
|
+
console.log(`revoked ${recs.length - kept.length} token(s) matching "${prefix}"`);
|
|
1432
|
+
} else {
|
|
1433
|
+
if (!recs.length) console.log('no client tokens issued');
|
|
1434
|
+
for (const r of recs) {
|
|
1435
|
+
const age = r.createdAt ? `${Math.round((Date.now() - r.createdAt) / 86400000)}d old` : 'undated';
|
|
1436
|
+
console.log(`${r.token.slice(0, 8)}… ${age}`);
|
|
1437
|
+
}
|
|
1438
|
+
console.log('\nrevoke with: fedipod tokens --revoke <prefix> (or --revoke-all)');
|
|
1439
|
+
}
|
|
1440
|
+
} else if (cmd === 'stop') {
|
|
1441
|
+
requireIdentity();
|
|
1442
|
+
const pidFile = path.join(HOME, 'agent.pid');
|
|
1443
|
+
let pid = null;
|
|
1444
|
+
try { pid = Number(fs.readFileSync(pidFile, 'utf8').trim()); } catch {}
|
|
1445
|
+
if (!pid) {
|
|
1446
|
+
// The agent may still be listening even with no pidfile — an older
|
|
1447
|
+
// build, a deleted file, or one started detached from any terminal
|
|
1448
|
+
// (where Ctrl-C can never reach it). Ask it to stop over the API.
|
|
1449
|
+
const asked = await localFetch(HOME, PORT, `/shutdown`, { method: 'POST' })
|
|
1450
|
+
.then(r => r.ok).catch(() => false);
|
|
1451
|
+
if (asked) { console.log(`agent on port ${PORT} asked to stop`); process.exit(0); }
|
|
1452
|
+
console.error(`no agent found: no pidfile at ${pidFile}, nothing answering on port ${PORT}.`);
|
|
1453
|
+
console.error('If it is on another port, add --port N; a service install stops with:');
|
|
1454
|
+
console.error(' systemctl --user stop fedipod-<name>');
|
|
1455
|
+
process.exit(1);
|
|
1456
|
+
}
|
|
1457
|
+
try { process.kill(pid, 'SIGTERM'); } catch {
|
|
1458
|
+
// Stale pidfile, but something may still hold the port.
|
|
1459
|
+
const asked = await localFetch(HOME, PORT, `/shutdown`, { method: 'POST' })
|
|
1460
|
+
.then(r => r.ok).catch(() => false);
|
|
1461
|
+
fs.rmSync(pidFile, { force: true });
|
|
1462
|
+
console.log(asked ? `agent on port ${PORT} asked to stop (pidfile was stale)` : 'agent was not running (stale pidfile)');
|
|
1463
|
+
process.exit(0);
|
|
1464
|
+
}
|
|
1465
|
+
const t0 = Date.now();
|
|
1466
|
+
while (Date.now() - t0 < 10_000) {
|
|
1467
|
+
await new Promise(r => setTimeout(r, 300));
|
|
1468
|
+
try { process.kill(pid, 0); } catch { console.log('agent stopped'); process.exit(0); }
|
|
1469
|
+
}
|
|
1470
|
+
console.error('agent did not exit within 10s — kill it with: kill -9 ' + pid);
|
|
1471
|
+
process.exit(1);
|
|
1472
|
+
} else if (cmd === 'passwd') {
|
|
1473
|
+
requireIdentity();
|
|
1474
|
+
const { Agent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
1475
|
+
const { hashPassword } = await import(new URL('../lib/mastoapi.mjs', import.meta.url));
|
|
1476
|
+
const { RemotePod } = await import(new URL('../lib/remote.mjs', import.meta.url));
|
|
1477
|
+
const { apUrls } = await import(new URL('../lib/wire.mjs', import.meta.url));
|
|
1478
|
+
const agent = new Agent({ home: HOME, log: () => {} });
|
|
1479
|
+
const cred = agent.readCredential();
|
|
1480
|
+
if (!cred) { console.error('no credential — run setup first'); process.exit(2); }
|
|
1481
|
+
const pw = await askHidden('new UI password: ');
|
|
1482
|
+
if (!pw) { console.error('empty password — aborted'); process.exit(2); }
|
|
1483
|
+
agent.remote = new RemotePod(cred);
|
|
1484
|
+
await agent.remote.warmup();
|
|
1485
|
+
agent.urls = apUrls(cred.remotePod, cred.root);
|
|
1486
|
+
agent.store.attach(agent.privateStorage(cred, 'state')); // honours privateRoot
|
|
1487
|
+
await agent.store.load();
|
|
1488
|
+
const config = agent.store.getConfig();
|
|
1489
|
+
if (!config) { console.error('pod state empty — run setup first'); process.exit(2); }
|
|
1490
|
+
agent.store.setConfig({ ...config, uiPassword: hashPassword(pw) });
|
|
1491
|
+
await agent.store.flush();
|
|
1492
|
+
console.log('UI password set — /oauth/authorize now shows a login form (restart a running agent to pick it up)');
|
|
1493
|
+
} else if (cmd === 'gateway' || cmd === 'front') {
|
|
1494
|
+
// Attach this identity to a gateway ('front' kept as an alias). Shapes:
|
|
1495
|
+
// fedipod gateway <.../ap/inbox/> --secret S --inbox-only the usual one:
|
|
1496
|
+
// identity stays on the pod; only the advertised inbox moves to the
|
|
1497
|
+
// gateway's door. Leaving is one republish with the pod inbox.
|
|
1498
|
+
// fedipod gateway <.../ap/actor> --secret S fronted identity
|
|
1499
|
+
// fedipod gateway --detach back to the pod inbox
|
|
1500
|
+
requireIdentity();
|
|
1501
|
+
if (has('detach')) {
|
|
1502
|
+
try {
|
|
1503
|
+
const res = await localFetch(HOME, PORT, `/gateway`, {
|
|
1504
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1505
|
+
body: JSON.stringify({ action: 'forget' }),
|
|
1506
|
+
});
|
|
1507
|
+
const body = await res.json();
|
|
1508
|
+
if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
|
|
1509
|
+
console.log('detached — the actor was republished advertising your pod\'s own inbox');
|
|
1510
|
+
} catch (e) {
|
|
1511
|
+
console.error(`agent not reachable on :${PORT} (${e.message}) — start it, then detach`);
|
|
1512
|
+
process.exit(1);
|
|
1513
|
+
}
|
|
1514
|
+
process.exit(0);
|
|
1515
|
+
}
|
|
1516
|
+
const target = args[1];
|
|
1517
|
+
const secret = flag('secret');
|
|
1518
|
+
const inboxOnly = has('inbox-only');
|
|
1519
|
+
const okActor = /^https:\/\/\S+\/ap\/actor$/.test(String(target || ''));
|
|
1520
|
+
const okInbox = /^https:\/\/\S+\/ap\/inbox\/?$/.test(String(target || ''));
|
|
1521
|
+
if (inboxOnly ? !okInbox : !okActor) {
|
|
1522
|
+
console.error(inboxOnly
|
|
1523
|
+
? 'usage: fedipod gateway <https://host/u/<name>/ap/inbox/> --secret <hmac> --inbox-only'
|
|
1524
|
+
: 'usage: fedipod gateway <https://host/u/<name>/ap/actor> --secret <hmac> [--inbox-only]');
|
|
1525
|
+
console.error(' (the URL and secret come from the gateway\'s signup page)');
|
|
1526
|
+
process.exit(2);
|
|
1527
|
+
}
|
|
1528
|
+
const { Agent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
1529
|
+
const { RemotePod } = await import(new URL('../lib/remote.mjs', import.meta.url));
|
|
1530
|
+
const { apUrls } = await import(new URL('../lib/wire.mjs', import.meta.url));
|
|
1531
|
+
const agent = new Agent({ home: HOME, log: () => {} });
|
|
1532
|
+
const cred = agent.readCredential();
|
|
1533
|
+
if (!cred) { console.error('no credential — run setup first'); process.exit(2); }
|
|
1534
|
+
agent.remote = new RemotePod(cred);
|
|
1535
|
+
await agent.remote.warmup();
|
|
1536
|
+
agent.urls = apUrls(cred.remotePod, cred.root);
|
|
1537
|
+
agent.store.attach(agent.privateStorage(cred, 'state'));
|
|
1538
|
+
await agent.store.load();
|
|
1539
|
+
const config = agent.store.getConfig();
|
|
1540
|
+
if (!config) { console.error('pod state empty — run setup first'); process.exit(2); }
|
|
1541
|
+
if (config.gateway?.frontActor && (inboxOnly || config.gateway.frontActor !== target)) {
|
|
1542
|
+
console.error(`this identity already fronts through ${config.gateway.frontActor}.`);
|
|
1543
|
+
console.error('Changing a published front renames every id — that is a move, not an edit. Detach first if you mean it.');
|
|
1544
|
+
process.exit(2);
|
|
1545
|
+
}
|
|
1546
|
+
const gateway = inboxOnly
|
|
1547
|
+
? { ...(config.gateway || {}), url: target.replace(/\/?$/, '/'), mode: 'shadow',
|
|
1548
|
+
...(secret ? { hmacSecret: secret } : {}) }
|
|
1549
|
+
: { ...(config.gateway || {}), url: target.replace(/ap\/actor$/, 'ap/inbox/'),
|
|
1550
|
+
frontActor: target, mode: 'shadow', ...(secret ? { hmacSecret: secret } : {}) };
|
|
1551
|
+
agent.store.setConfig({ ...config, gateway });
|
|
1552
|
+
await agent.store.flush();
|
|
1553
|
+
if (inboxOnly) {
|
|
1554
|
+
console.log('attached to the shared filter — your identity stays on your pod;');
|
|
1555
|
+
console.log(`the actor will advertise ${gateway.url} as its inbox.`);
|
|
1556
|
+
console.log('Starting in shadow: the door filters, and the agent measures how much');
|
|
1557
|
+
console.log('verifies before it believes any receipt. Move to trust when you are ready.');
|
|
1558
|
+
} else {
|
|
1559
|
+
console.log(`attached to the front — this identity now publishes as ${target}`);
|
|
1560
|
+
}
|
|
1561
|
+
console.log('Restart the agent (or `fedipod up`) to republish the actor.');
|
|
1562
|
+
if (!secret) console.log('No --secret given: pass the front\'s receipt secret to trust its verification.');
|
|
1563
|
+
} else if (cmd === 'keys') {
|
|
1564
|
+
// Where the signing key lives; `--to pod` moves it into pod state — with
|
|
1565
|
+
// the state store on a pod every device reaches, that is what lets them
|
|
1566
|
+
// all sign as one actor. `--to local` flips back: the next start adopts
|
|
1567
|
+
// the pod copy onto this machine and removes it from the pod.
|
|
1568
|
+
requireIdentity();
|
|
1569
|
+
const localKeyFile = path.join(HOME, 'keys.json');
|
|
1570
|
+
const to = flag('to');
|
|
1571
|
+
if (!to) {
|
|
1572
|
+
const cred0 = JSON.parse(fs.readFileSync(path.join(HOME, 'credential.json'), 'utf8'));
|
|
1573
|
+
console.log(fs.existsSync(localKeyFile)
|
|
1574
|
+
? `signing key: ${tildify(localKeyFile)} (this machine only)`
|
|
1575
|
+
: cred0.keysMode === 'pod'
|
|
1576
|
+
? 'signing key: pod state (shared by every device that reaches the state store)'
|
|
1577
|
+
: 'signing key: none found here — it mints on the next start');
|
|
1578
|
+
process.exit(0);
|
|
1579
|
+
}
|
|
1580
|
+
if (!['pod', 'local'].includes(to)) {
|
|
1581
|
+
console.error('usage: fedipod keys [--to pod|local]');
|
|
1582
|
+
process.exit(2);
|
|
1583
|
+
}
|
|
1584
|
+
// The running agent holds the key and the config in memory — stop it first.
|
|
1585
|
+
const answering = await localFetch(HOME, PORT, `/status`).then(r => r.ok).catch(() => false);
|
|
1586
|
+
if (answering) {
|
|
1587
|
+
console.error(`an agent is answering on :${PORT} — stop it first (fedipod stop), then re-run`);
|
|
1588
|
+
process.exit(2);
|
|
1589
|
+
}
|
|
1590
|
+
const credPath = path.join(HOME, 'credential.json');
|
|
1591
|
+
const cred = JSON.parse(fs.readFileSync(credPath, 'utf8'));
|
|
1592
|
+
if (to === 'local') {
|
|
1593
|
+
if (cred.keysMode !== 'pod') { console.log('keys are already local'); process.exit(0); }
|
|
1594
|
+
delete cred.keysMode;
|
|
1595
|
+
writeJsonAtomic(credPath, cred);
|
|
1596
|
+
console.log('keys set to local — the next start adopts the pod copy onto this machine and removes it from the pod');
|
|
1597
|
+
process.exit(0);
|
|
1598
|
+
}
|
|
1599
|
+
if (cred.keysMode === 'pod' && !fs.existsSync(localKeyFile)) {
|
|
1600
|
+
console.log('keys are already pod-held');
|
|
1601
|
+
process.exit(0);
|
|
1602
|
+
}
|
|
1603
|
+
const { Agent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
1604
|
+
const { RemotePod } = await import(new URL('../lib/remote.mjs', import.meta.url));
|
|
1605
|
+
const { apUrls } = await import(new URL('../lib/wire.mjs', import.meta.url));
|
|
1606
|
+
const { moveKeysToPod } = await import(new URL('../lib/keys.mjs', import.meta.url));
|
|
1607
|
+
const agent = new Agent({ home: HOME, log: () => {} });
|
|
1608
|
+
agent.remote = new RemotePod(cred);
|
|
1609
|
+
await agent.remote.warmup();
|
|
1610
|
+
agent.store.attach(agent.privateStorage(cred, 'state'));
|
|
1611
|
+
await agent.store.load();
|
|
1612
|
+
const gwActor = agent.store.getConfig()?.gateway?.frontActor;
|
|
1613
|
+
const urls = apUrls(cred.remotePod, cred.root,
|
|
1614
|
+
gwActor ? { publicBase: gwActor.replace(/ap\/actor\/?$/, '') } : undefined);
|
|
1615
|
+
try {
|
|
1616
|
+
await moveKeysToPod(agent.store, { localDir: HOME, actorId: urls.actor, log: console.log });
|
|
1617
|
+
} catch (e) {
|
|
1618
|
+
console.error(e.message);
|
|
1619
|
+
process.exit(1);
|
|
1620
|
+
}
|
|
1621
|
+
cred.keysMode = 'pod';
|
|
1622
|
+
writeJsonAtomic(credPath, cred);
|
|
1623
|
+
if (cred.privateRoot) {
|
|
1624
|
+
console.log(`note: this identity's state store is ${cred.privateRoot} — devices share the key`);
|
|
1625
|
+
console.log('only when the state store is a pod they can all reach (`fedipod state --to pod`).');
|
|
1626
|
+
}
|
|
1627
|
+
console.log('done — restart the agent');
|
|
1628
|
+
} else if (cmd === 'update') {
|
|
1629
|
+
// Pull the latest published FediPod into this checkout and restart the
|
|
1630
|
+
// agents — what re-running the installer does, as one command.
|
|
1631
|
+
const { checkLatest, runUpdate, restartAgents } = await import(new URL('../lib/update.mjs', import.meta.url));
|
|
1632
|
+
const u = await checkLatest();
|
|
1633
|
+
if (u && !u.available) console.log(`already current: ${u.current}`);
|
|
1634
|
+
const r = runUpdate({ log: console.log });
|
|
1635
|
+
if (!r.ok) { console.error(r.note); process.exit(1); }
|
|
1636
|
+
console.log(r.note);
|
|
1637
|
+
if (restartAgents({ log: console.log }) === 'self') {
|
|
1638
|
+
console.log('no managed agents found — restart any running agent to serve the new version');
|
|
1639
|
+
}
|
|
1640
|
+
process.exit(0);
|
|
1641
|
+
} else if (cmd === 'https') {
|
|
1642
|
+
// Agents serve https beside http with a certificate minted on this machine
|
|
1643
|
+
// (never packaged — a shipped key would be one key for every install).
|
|
1644
|
+
// Plain: self-signed, a client may ask once. --trust: a local CA signs it,
|
|
1645
|
+
// and the CA certificate is what a trust store accepts — for clients that
|
|
1646
|
+
// refuse self-signed outright.
|
|
1647
|
+
const { ensureLocalTls, enableTrust, certPaths } = await import(new URL('../lib/certs.mjs', import.meta.url));
|
|
1648
|
+
const certDir = path.join(rootOf(HOME), 'certs'); // same resolution the agent uses
|
|
1649
|
+
if (has('trust')) {
|
|
1650
|
+
const t = enableTrust(certDir, { log: console.log }); // mints, signs, installs in NSS
|
|
1651
|
+
console.log(`CA certificate: ${t.caCertPath}`);
|
|
1652
|
+
if (process.platform === 'linux') {
|
|
1653
|
+
console.log('for the system store, run:');
|
|
1654
|
+
console.log(` sudo cp ${t.caCertPath} /usr/local/share/ca-certificates/fedipod-local-ca.crt && sudo update-ca-certificates`);
|
|
1655
|
+
} else if (process.platform === 'darwin') {
|
|
1656
|
+
console.log('to trust it system-wide, run:');
|
|
1657
|
+
console.log(` sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ${t.caCertPath}`);
|
|
1658
|
+
} else if (process.platform === 'win32') {
|
|
1659
|
+
console.log('to trust it, run:');
|
|
1660
|
+
console.log(` certutil -addstore -user Root "${t.caCertPath}"`);
|
|
1661
|
+
}
|
|
1662
|
+
console.log('restart your agents to serve the CA-signed certificate');
|
|
1663
|
+
} else {
|
|
1664
|
+
const tls = ensureLocalTls(certDir, { log: console.log });
|
|
1665
|
+
const paths2 = certPaths(certDir);
|
|
1666
|
+
console.log(`certificate: ${paths2.cert}`);
|
|
1667
|
+
console.log(` ${tls.trust ? 'signed by the local CA' : 'self-signed'}, ${tls.expiresInDays} days left`);
|
|
1668
|
+
console.log('agents serve https on the port they were given — e.g. https://localhost:8030');
|
|
1669
|
+
console.log('strict clients: `fedipod https --trust` mints a local CA your trust store can accept');
|
|
1670
|
+
}
|
|
1671
|
+
} else if (cmd === 'install-service' || cmd === 'uninstall-service') {
|
|
1672
|
+
const { execFileSync } = await import('node:child_process');
|
|
1673
|
+
const runAgentPath = new URL('../run-agent.mjs', import.meta.url).pathname;
|
|
1674
|
+
const sh = (file, a) => { try { execFileSync(file, a, { stdio: 'pipe' }); return true; } catch { return false; } };
|
|
1675
|
+
// Every identity with a credential gets a service of its own: a stopped
|
|
1676
|
+
// actor's pod goes on collecting deliveries, so "installed" means ALL of
|
|
1677
|
+
// them start at boot, each on its recorded port.
|
|
1678
|
+
const identities = [];
|
|
1679
|
+
for (const { name, dir } of identityHomes(AP_ROOT)) {
|
|
1680
|
+
if (!fs.existsSync(path.join(dir, 'credential.json'))) continue;
|
|
1681
|
+
let rec = {};
|
|
1682
|
+
try { rec = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')) || {}; } catch { /* no record yet */ }
|
|
1683
|
+
if (!Number(rec.port)) {
|
|
1684
|
+
console.log(`${name}: no recorded port — start it once (\`up --profile ${name}\`), then re-run install-service`);
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
identities.push({ name, dir, port: Number(rec.port), handle: rec.handle || name });
|
|
1688
|
+
}
|
|
1689
|
+
if (cmd === 'install-service' && !identities.length) {
|
|
1690
|
+
console.error('no identities with a credential and a recorded port — nothing to install');
|
|
1691
|
+
process.exit(2);
|
|
1692
|
+
}
|
|
1693
|
+
// Graceful handover: an identity already running outside the service is
|
|
1694
|
+
// stopped through its own /shutdown so the unit can take the port.
|
|
1695
|
+
const handOver = async ({ name, port }) => {
|
|
1696
|
+
if (!await agentOn(port)) return;
|
|
1697
|
+
await localFetch(HOME, port, `/shutdown`, { method: 'POST' }).catch(() => {});
|
|
1698
|
+
for (let i = 0; i < 20 && !await portFree(port); i++) await new Promise(r => setTimeout(r, 250));
|
|
1699
|
+
console.log(`${name}: was running detached — stopped for the service to take over`);
|
|
1700
|
+
};
|
|
1701
|
+
|
|
1702
|
+
if (process.platform === 'linux') {
|
|
1703
|
+
const unitDir = path.join(os.homedir(), '.config/systemd/user');
|
|
1704
|
+
const unitOf = (name) => `fedipod-${name}.service`;
|
|
1705
|
+
// Old shapes are cleared on both paths: units under the pre-rename names
|
|
1706
|
+
// (activitypod, solid-activitypub), single-identity units, and any
|
|
1707
|
+
// per-identity unit for an identity that no longer exists here.
|
|
1708
|
+
const dropOld = () => {
|
|
1709
|
+
const keep = new Set(cmd === 'install-service' ? identities.map(i => unitOf(i.name)) : []);
|
|
1710
|
+
let units = [];
|
|
1711
|
+
try { units = fs.readdirSync(unitDir).filter(u => /^(activitypod|solid-activitypub|fedipod)(-.+)?\.service$/.test(u)); } catch { /* no unit dir */ }
|
|
1712
|
+
for (const u of units) {
|
|
1713
|
+
if (keep.has(u)) continue;
|
|
1714
|
+
sh('systemctl', ['--user', 'disable', '--now', u]);
|
|
1715
|
+
fs.rmSync(path.join(unitDir, u), { force: true });
|
|
1716
|
+
console.log(`removed ${u}`);
|
|
1717
|
+
}
|
|
1718
|
+
};
|
|
1719
|
+
if (cmd === 'uninstall-service') {
|
|
1720
|
+
dropOld();
|
|
1721
|
+
sh('systemctl', ['--user', 'daemon-reload']);
|
|
1722
|
+
console.log('service(s) removed');
|
|
1723
|
+
} else {
|
|
1724
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
1725
|
+
dropOld();
|
|
1726
|
+
for (const id of identities) {
|
|
1727
|
+
fs.writeFileSync(path.join(unitDir, unitOf(id.name)), `[Unit]
|
|
1728
|
+
Description=FediPod agent — ${id.handle}
|
|
1729
|
+
After=network-online.target
|
|
1730
|
+
|
|
1731
|
+
[Service]
|
|
1732
|
+
ExecStart=${process.execPath} ${runAgentPath}
|
|
1733
|
+
Environment=AP_HOME=${id.dir}
|
|
1734
|
+
Environment=AP_PORT=${id.port}
|
|
1735
|
+
Restart=on-failure
|
|
1736
|
+
RestartSec=30
|
|
1737
|
+
# A crash loop must not become a request loop against the pod.
|
|
1738
|
+
StartLimitIntervalSec=600
|
|
1739
|
+
StartLimitBurst=5
|
|
1740
|
+
|
|
1741
|
+
[Install]
|
|
1742
|
+
WantedBy=default.target
|
|
1743
|
+
`);
|
|
1744
|
+
}
|
|
1745
|
+
sh('systemctl', ['--user', 'daemon-reload']);
|
|
1746
|
+
sh('loginctl', ['enable-linger', os.userInfo().username]); // keep running while logged out
|
|
1747
|
+
for (const id of identities) {
|
|
1748
|
+
sh('systemctl', ['--user', 'enable', unitOf(id.name)]);
|
|
1749
|
+
await handOver(id);
|
|
1750
|
+
if (await portFree(id.port)) {
|
|
1751
|
+
sh('systemctl', ['--user', 'start', unitOf(id.name)]);
|
|
1752
|
+
console.log(`${id.handle}: installed, enabled and started on port ${id.port}`);
|
|
1753
|
+
} else {
|
|
1754
|
+
console.log(`${id.handle}: installed + enabled (starts at next boot). Port ${id.port} is held by something that is not ours — free it, then: systemctl --user start ${unitOf(id.name)}`);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
console.log('logs: journalctl --user -u fedipod-<name> -f');
|
|
1758
|
+
}
|
|
1759
|
+
} else if (process.platform === 'darwin') {
|
|
1760
|
+
const agents = path.join(os.homedir(), 'Library/LaunchAgents');
|
|
1761
|
+
const plistOf = (name) => path.join(agents, `net.fedipod.${name}.agent.plist`);
|
|
1762
|
+
// Old shapes are cleared on both paths: plists under the pre-rename names
|
|
1763
|
+
// (net.activitypod, net.solid-activitypub), single-identity plists, and any
|
|
1764
|
+
// per-identity plist for an identity that no longer exists here.
|
|
1765
|
+
const dropOld = () => {
|
|
1766
|
+
const keep = new Set(cmd === 'install-service' ? identities.map(i => plistOf(i.name)) : []);
|
|
1767
|
+
let plists = [];
|
|
1768
|
+
try {
|
|
1769
|
+
plists = fs.readdirSync(agents)
|
|
1770
|
+
.filter(f => /^net\.(activitypod|solid-activitypub|fedipod)(\..+)?\.agent\.plist$/.test(f))
|
|
1771
|
+
.map(f => path.join(agents, f));
|
|
1772
|
+
} catch { /* no LaunchAgents dir */ }
|
|
1773
|
+
for (const p of plists) {
|
|
1774
|
+
if (keep.has(p)) continue;
|
|
1775
|
+
sh('launchctl', ['unload', p]);
|
|
1776
|
+
fs.rmSync(p, { force: true });
|
|
1777
|
+
console.log(`removed ${path.basename(p)}`);
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
if (cmd === 'uninstall-service') {
|
|
1781
|
+
dropOld();
|
|
1782
|
+
console.log('service(s) removed');
|
|
1783
|
+
} else {
|
|
1784
|
+
fs.mkdirSync(agents, { recursive: true });
|
|
1785
|
+
dropOld();
|
|
1786
|
+
for (const id of identities) {
|
|
1787
|
+
const plist = plistOf(id.name);
|
|
1788
|
+
sh('launchctl', ['unload', plist]); // replacing our own older copy
|
|
1789
|
+
fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
1790
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1791
|
+
<plist version="1.0"><dict>
|
|
1792
|
+
<key>Label</key><string>net.fedipod.${id.name}.agent</string>
|
|
1793
|
+
<key>ProgramArguments</key><array>
|
|
1794
|
+
<string>${process.execPath}</string><string>${runAgentPath}</string>
|
|
1795
|
+
</array>
|
|
1796
|
+
<key>EnvironmentVariables</key><dict>
|
|
1797
|
+
<key>AP_HOME</key><string>${id.dir}</string>
|
|
1798
|
+
<key>AP_PORT</key><string>${id.port}</string>
|
|
1799
|
+
</dict>
|
|
1800
|
+
<key>RunAtLoad</key><true/>
|
|
1801
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
1802
|
+
<key>ThrottleInterval</key><integer>30</integer>
|
|
1803
|
+
</dict></plist>
|
|
1804
|
+
`);
|
|
1805
|
+
await handOver(id);
|
|
1806
|
+
sh('launchctl', ['load', plist]);
|
|
1807
|
+
console.log(`${id.handle}: installed and loaded (starts at login)`);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
} else if (process.platform === 'win32') {
|
|
1811
|
+
// schtasks is scriptable, but this path is UNTESTED here (no Windows
|
|
1812
|
+
// machine); the equivalent command is printed either way so a failure
|
|
1813
|
+
// is actionable rather than mysterious.
|
|
1814
|
+
const taskOf = (name) => `fedipod-${name}`;
|
|
1815
|
+
// Pre-rename names, single-identity and per-identity, cleared on both paths.
|
|
1816
|
+
const dropOld = () => {
|
|
1817
|
+
sh('schtasks', ['/delete', '/tn', 'activitypod', '/f']);
|
|
1818
|
+
sh('schtasks', ['/delete', '/tn', 'solid-activitypub', '/f']);
|
|
1819
|
+
for (const id of identities) sh('schtasks', ['/delete', '/tn', `solid-activitypub-${id.name}`, '/f']);
|
|
1820
|
+
};
|
|
1821
|
+
if (cmd === 'uninstall-service') {
|
|
1822
|
+
dropOld();
|
|
1823
|
+
for (const id of identities) sh('schtasks', ['/delete', '/tn', taskOf(id.name), '/f']);
|
|
1824
|
+
console.log('scheduled task(s) removed');
|
|
1825
|
+
} else {
|
|
1826
|
+
dropOld();
|
|
1827
|
+
for (const id of identities) {
|
|
1828
|
+
const tr = `"${process.execPath}" "${runAgentPath}"`;
|
|
1829
|
+
const made = sh('schtasks', ['/create', '/tn', taskOf(id.name), '/tr', tr, '/sc', 'onlogon', '/rl', 'limited', '/f']);
|
|
1830
|
+
if (made) {
|
|
1831
|
+
console.log(`${id.handle}: scheduled task created — starts at log on (untested on Windows; please report)`);
|
|
1832
|
+
console.log(` set AP_HOME=${id.dir} and AP_PORT=${id.port} in the task's environment`);
|
|
1833
|
+
} else {
|
|
1834
|
+
console.log(`${id.handle}: could not create the task automatically. Run this in an elevated prompt:`);
|
|
1835
|
+
console.log(` schtasks /create /tn ${taskOf(id.name)} /tr ${tr} /sc onlogon /rl limited /f`);
|
|
1836
|
+
console.log(` with AP_HOME=${id.dir} AP_PORT=${id.port}.`);
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
} else if (process.platform === 'android' || process.env.PREFIX?.includes('com.termux')) {
|
|
1841
|
+
// Android has no user service manager: running at boot needs the
|
|
1842
|
+
// separate termux-boot app, supervision needs the termux-services
|
|
1843
|
+
// package. Neither can be installed from here, so print the recipe.
|
|
1844
|
+
// The agent is designed for this: whatever Android kills, the pod
|
|
1845
|
+
// buffered, and the next start catches up.
|
|
1846
|
+
if (cmd === 'uninstall-service') {
|
|
1847
|
+
console.log('Termux: remove ~/.termux/boot/fedipod.sh (solid-activitypub.sh or activitypod.sh on an older install, and `sv-disable` the matching service if you used termux-services).');
|
|
1848
|
+
} else {
|
|
1849
|
+
const boot = path.join(os.homedir(), '.termux/boot');
|
|
1850
|
+
console.log('Android/Termux has no service manager. To start at boot:');
|
|
1851
|
+
console.log(' 1. install the Termux:Boot app (F-Droid), open it once');
|
|
1852
|
+
console.log(` 2. mkdir -p ${boot} && cat > ${boot}/fedipod.sh <<'EOF'`);
|
|
1853
|
+
console.log('#!/data/data/com.termux/files/usr/bin/sh');
|
|
1854
|
+
console.log('termux-wake-lock');
|
|
1855
|
+
for (const id of identities) console.log(`AP_HOME=${id.dir} AP_PORT=${id.port} ${process.execPath} ${runAgentPath} &`);
|
|
1856
|
+
console.log('EOF');
|
|
1857
|
+
console.log(` 3. chmod +x ${boot}/fedipod.sh`);
|
|
1858
|
+
console.log('\nWithout Termux:Boot, run `termux-wake-lock` then `fedipod run` —');
|
|
1859
|
+
console.log('anything Android kills is buffered on the pod and catches up next start.');
|
|
1860
|
+
}
|
|
1861
|
+
} else {
|
|
1862
|
+
console.log(`no service integration for platform "${process.platform}". Run each yourself with:`);
|
|
1863
|
+
for (const id of identities) console.log(` AP_HOME=${id.dir} AP_PORT=${id.port} ${process.execPath} ${runAgentPath}`);
|
|
1864
|
+
}
|
|
1865
|
+
} else if (cmd === 'describe') {
|
|
1866
|
+
// The bio and the avatar. Both live in the actor document, so this republishes.
|
|
1867
|
+
if (!flag('summary') && !flag('icon')) {
|
|
1868
|
+
console.error('usage: fedipod describe --summary "what this is" --icon <url>');
|
|
1869
|
+
process.exit(2);
|
|
1870
|
+
}
|
|
1871
|
+
const payload = {};
|
|
1872
|
+
if (flag('summary') !== undefined) payload.summary = flag('summary');
|
|
1873
|
+
if (flag('icon') !== undefined) payload.icon = flag('icon');
|
|
1874
|
+
try {
|
|
1875
|
+
const res = await localFetch(HOME, PORT, `/describe`, {
|
|
1876
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload),
|
|
1877
|
+
});
|
|
1878
|
+
const body = await res.json();
|
|
1879
|
+
if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
|
|
1880
|
+
console.log(`described and republished — summary: ${body.summary ? '"' + body.summary + '"' : '(none)'}, icon: ${body.icon || '(none)'}`);
|
|
1881
|
+
} catch (e) {
|
|
1882
|
+
console.error(`agent not reachable on :${PORT} (${e.message})`);
|
|
1883
|
+
process.exit(1);
|
|
1884
|
+
}
|
|
1885
|
+
} else if (cmd === 'alias') {
|
|
1886
|
+
// The migration landing pad: old accounts listed in alsoKnownAs, which their
|
|
1887
|
+
// servers check before sending a Move here. Served by the running agent
|
|
1888
|
+
// because adding one resolves the old account and republishes the actor.
|
|
1889
|
+
requireIdentity();
|
|
1890
|
+
try {
|
|
1891
|
+
if (flag('add')) {
|
|
1892
|
+
const res = await localFetch(HOME, PORT, `/alias`, {
|
|
1893
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1894
|
+
body: JSON.stringify({ add: flag('add') }),
|
|
1895
|
+
});
|
|
1896
|
+
const body = await res.json();
|
|
1897
|
+
if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
|
|
1898
|
+
console.log('alias added and the actor republished — aliases now:');
|
|
1899
|
+
for (const a of body.aliases) console.log(` ${a}`);
|
|
1900
|
+
console.log('on the old server, Account → Move to a different account will now accept this one');
|
|
1901
|
+
} else if (flag('remove')) {
|
|
1902
|
+
const res = await localFetch(HOME, PORT, `/alias`, {
|
|
1903
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1904
|
+
body: JSON.stringify({ remove: flag('remove'), ...(has('yes') ? { confirm: true } : {}) }),
|
|
1905
|
+
});
|
|
1906
|
+
const body = await res.json();
|
|
1907
|
+
if (res.status === 409) { console.error(body.error); console.error('add --yes to remove it anyway'); process.exit(1); }
|
|
1908
|
+
if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
|
|
1909
|
+
console.log(body.aliases.length ? 'removed — aliases now:' : 'removed — no aliases left');
|
|
1910
|
+
for (const a of body.aliases) console.log(` ${a}`);
|
|
1911
|
+
} else {
|
|
1912
|
+
const res = await localFetch(HOME, PORT, `/config`);
|
|
1913
|
+
const body = await res.json();
|
|
1914
|
+
if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
|
|
1915
|
+
const aliases = body.aliases || [];
|
|
1916
|
+
if (!aliases.length) console.log('no aliases — fedipod alias --add @you@old.server');
|
|
1917
|
+
for (const a of aliases) console.log(a);
|
|
1918
|
+
}
|
|
1919
|
+
} catch (e) {
|
|
1920
|
+
console.error(`agent not reachable on :${PORT} (${e.message})`);
|
|
1921
|
+
process.exit(1);
|
|
1922
|
+
}
|
|
1923
|
+
} else if (cmd === 'import') {
|
|
1924
|
+
// The CSV exports the old account's server hands its leaver, staged with the
|
|
1925
|
+
// running agent and applied by its paced worker. File names tell the kinds —
|
|
1926
|
+
// the names Mastodon's export uses, which the rest of the family copies.
|
|
1927
|
+
requireIdentity();
|
|
1928
|
+
const NOVAL = new Set(['--clear', '--status']);
|
|
1929
|
+
const files = [];
|
|
1930
|
+
for (let i = 1; i < args.length; i++) {
|
|
1931
|
+
const a = args[i];
|
|
1932
|
+
if (a.startsWith('--')) { if (!NOVAL.has(a)) i++; continue; }
|
|
1933
|
+
files.push(a);
|
|
1934
|
+
}
|
|
1935
|
+
const jpost = (body) => localFetch(HOME, PORT, `/import`, {
|
|
1936
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
1937
|
+
}).then(async r => ({ status: r.status, json: await r.json().catch(() => null) }));
|
|
1938
|
+
const jget = () => localFetch(HOME, PORT, `/import`)
|
|
1939
|
+
.then(async r => ({ status: r.status, json: await r.json().catch(() => null) }));
|
|
1940
|
+
const showProgress = (p) => {
|
|
1941
|
+
const parts = Object.entries(p.byKind || {}).map(([k, c]) =>
|
|
1942
|
+
`${k} ${c.done}/${c.done + c.pending + c.failed}${c.failed ? ` (${c.failed} failed)` : ''}`);
|
|
1943
|
+
console.log(parts.join(', ') || 'nothing staged');
|
|
1944
|
+
};
|
|
1945
|
+
const kindOf = (file) => {
|
|
1946
|
+
const b = path.basename(file).toLowerCase();
|
|
1947
|
+
if (b.includes('following')) return 'follow';
|
|
1948
|
+
if (b.includes('domain')) return 'domain';
|
|
1949
|
+
if (b.includes('block')) return 'block';
|
|
1950
|
+
if (b.includes('mute')) return 'mute';
|
|
1951
|
+
if (b.includes('list')) return 'list';
|
|
1952
|
+
if (b.includes('bookmark')) return 'bookmark';
|
|
1953
|
+
return null;
|
|
1954
|
+
};
|
|
1955
|
+
try {
|
|
1956
|
+
if (has('clear')) {
|
|
1957
|
+
const r = await jpost({ clear: true });
|
|
1958
|
+
if (r.status >= 400) { console.error(r.json?.error || `HTTP ${r.status}`); process.exit(1); }
|
|
1959
|
+
console.log('import record cleared');
|
|
1960
|
+
} else if (!files.length) {
|
|
1961
|
+
const r = await jget();
|
|
1962
|
+
if (r.status >= 400) { console.error(r.json?.error || `HTTP ${r.status}`); process.exit(1); }
|
|
1963
|
+
if (!r.json.rows) {
|
|
1964
|
+
console.log('nothing imported yet — usage: fedipod import <csv-file…> [--kind follow|block|mute|list|domain]');
|
|
1965
|
+
} else {
|
|
1966
|
+
showProgress(r.json);
|
|
1967
|
+
for (const f of r.json.failures || []) console.log(` failed ${f.kind} ${f.value}: ${f.reason}`);
|
|
1968
|
+
}
|
|
1969
|
+
} else {
|
|
1970
|
+
for (const file of files) {
|
|
1971
|
+
const kind = flag('kind') || kindOf(file);
|
|
1972
|
+
if (kind === 'bookmark') { console.log(`${file}: bookmarks are not imported — skipped`); continue; }
|
|
1973
|
+
if (!['follow', 'block', 'mute', 'list', 'domain'].includes(kind)) {
|
|
1974
|
+
console.error(`${file}: cannot tell what this is from its name — pass --kind follow|block|mute|list|domain`);
|
|
1975
|
+
process.exit(2);
|
|
1976
|
+
}
|
|
1977
|
+
let text;
|
|
1978
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
1979
|
+
catch (e) { console.error(`${file}: ${e.message}`); process.exit(2); }
|
|
1980
|
+
// Stay under the agent's request cap, splitting on line boundaries —
|
|
1981
|
+
// but never inside a quoted field, where a newline is field content:
|
|
1982
|
+
// a boundary is only taken while the quotes seen so far are balanced.
|
|
1983
|
+
const chunks = [];
|
|
1984
|
+
let chunk = [], size = 0, quotes = 0;
|
|
1985
|
+
for (const line of text.split('\n')) {
|
|
1986
|
+
if (size + line.length > 800_000 && chunk.length && quotes % 2 === 0) {
|
|
1987
|
+
chunks.push(chunk.join('\n')); chunk = []; size = 0;
|
|
1988
|
+
}
|
|
1989
|
+
chunk.push(line);
|
|
1990
|
+
size += line.length + 1;
|
|
1991
|
+
quotes += (line.match(/"/g) || []).length;
|
|
1992
|
+
}
|
|
1993
|
+
if (chunk.length) chunks.push(chunk.join('\n'));
|
|
1994
|
+
let staged = 0, duplicate = 0, already = 0, invalid = 0;
|
|
1995
|
+
for (const c of chunks) {
|
|
1996
|
+
const r = await jpost({ kind, text: c });
|
|
1997
|
+
if (r.status >= 400) { console.error(`${file}: ${r.json?.error || `HTTP ${r.status}`}`); process.exit(1); }
|
|
1998
|
+
staged += r.json.staged; duplicate += r.json.duplicate;
|
|
1999
|
+
already += r.json.already; invalid += r.json.invalid;
|
|
2000
|
+
}
|
|
2001
|
+
console.log(`${file}: staged ${staged} ${kind} row(s)`
|
|
2002
|
+
+ (already ? `, ${already} already applied` : '')
|
|
2003
|
+
+ (duplicate ? `, ${duplicate} already staged` : '')
|
|
2004
|
+
+ (invalid ? `, ${invalid} unreadable` : ''));
|
|
2005
|
+
}
|
|
2006
|
+
// Watch it run. The worker paces itself, so a big list takes a while;
|
|
2007
|
+
// ctrl-c leaves it running and `fedipod import` shows where it is.
|
|
2008
|
+
let last = '';
|
|
2009
|
+
for (;;) {
|
|
2010
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
2011
|
+
const r = await jget();
|
|
2012
|
+
if (r.status >= 400) break;
|
|
2013
|
+
const p = r.json;
|
|
2014
|
+
const line = `applied ${p.done} of ${p.rows}${p.failed ? ` (${p.failed} failed)` : ''}`;
|
|
2015
|
+
if (line !== last) { console.log(line); last = line; }
|
|
2016
|
+
if (!p.pending) {
|
|
2017
|
+
for (const f of (p.failures || []).slice(0, 20)) console.log(` failed ${f.kind} ${f.value}: ${f.reason}`);
|
|
2018
|
+
if ((p.failures || []).length > 20) {
|
|
2019
|
+
console.log(` … and ${p.failures.length - 20} more — \`fedipod import\` lists them`);
|
|
2020
|
+
}
|
|
2021
|
+
break;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
} catch (e) {
|
|
2026
|
+
console.error(`agent not reachable on :${PORT} (${e.message})`);
|
|
2027
|
+
process.exit(1);
|
|
2028
|
+
}
|
|
2029
|
+
} else if (cmd === 'rebuild') {
|
|
2030
|
+
requireIdentity();
|
|
2031
|
+
// Served by the running agent because it needs the lease: it writes the
|
|
2032
|
+
// statuses store, and two agents writing it is the thing the lease prevents.
|
|
2033
|
+
try {
|
|
2034
|
+
const res = await localFetch(HOME, PORT, `/rebuild`, {
|
|
2035
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
2036
|
+
body: JSON.stringify({ fromNotes: has('from-notes') }),
|
|
2037
|
+
});
|
|
2038
|
+
const body = await res.json();
|
|
2039
|
+
if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
|
|
2040
|
+
if (body.why) { console.error(body.why); process.exit(1); }
|
|
2041
|
+
console.log(`the pod indexed ${body.indexed} post(s); recovered ${body.recovered}`
|
|
2042
|
+
+ `${body.reblogs ? `, and marked ${body.reblogs} of them boosted` : ''}`
|
|
2043
|
+
+ `${body.rdf ? ` (${body.rdf} written back to the RDF)` : ''}`);
|
|
2044
|
+
if (body.dropped) console.log(`${body.dropped} fell past the 1000-status cap`);
|
|
2045
|
+
if (!body.landed) { console.error('the state write did NOT land — nothing is saved'); process.exit(1); }
|
|
2046
|
+
if (!body.recovered && !has('from-notes')) {
|
|
2047
|
+
console.log('Nothing was missing. `--from-notes` looks past the outbox, at every note the pod still holds.');
|
|
2048
|
+
}
|
|
2049
|
+
} catch (e) {
|
|
2050
|
+
console.error(`agent not reachable on :${PORT} (${e.message})`);
|
|
2051
|
+
process.exit(1);
|
|
2052
|
+
}
|
|
2053
|
+
} else if (cmd === 'status') {
|
|
2054
|
+
requireIdentity();
|
|
2055
|
+
try {
|
|
2056
|
+
const res = await localFetch(HOME, PORT, `/status`);
|
|
2057
|
+
console.log(JSON.stringify(await res.json(), null, 2));
|
|
2058
|
+
} catch (e) {
|
|
2059
|
+
console.error(`agent not reachable on :${PORT} (${e.message})`);
|
|
2060
|
+
process.exit(1);
|
|
2061
|
+
}
|
|
2062
|
+
} else if (['members', 'announced', 'pending', 'requests', 'mute', 'unmute', 'eject',
|
|
2063
|
+
'retract', 'approve', 'decline', 'review', 'joins', 'admit', 'refuse',
|
|
2064
|
+
'modqueue'].includes(cmd)) {
|
|
2065
|
+
// Group operator commands, served by the running agent's admin API.
|
|
2066
|
+
const GETS = ['members', 'announced', 'pending', 'requests', 'modqueue'];
|
|
2067
|
+
const BY_ACTOR = ['mute', 'unmute', 'eject', 'admit', 'refuse'];
|
|
2068
|
+
const TOGGLES = { review: ['on', 'off'], joins: ['open', 'approve'] };
|
|
2069
|
+
const post = !GETS.includes(cmd);
|
|
2070
|
+
const admitAll = cmd === 'admit' && has('all');
|
|
2071
|
+
const modAct = cmd === 'modqueue' ? (flag('apply') || flag('dismiss') || null) : null;
|
|
2072
|
+
const modVerb = cmd === 'modqueue' && flag('dismiss') ? 'dismiss' : 'apply';
|
|
2073
|
+
const arg = post ? (flag('actor') || flag('note') || args[1]) : null;
|
|
2074
|
+
if (post && !TOGGLES[cmd] && !arg && !admitAll) {
|
|
2075
|
+
console.error(`usage: fedipod ${cmd} <${BY_ACTOR.includes(cmd) ? 'actor' : 'note'}-url>`
|
|
2076
|
+
+ (cmd === 'admit' ? ' (or: fedipod admit --all)' : ''));
|
|
2077
|
+
process.exit(2);
|
|
2078
|
+
}
|
|
2079
|
+
if (TOGGLES[cmd] && !TOGGLES[cmd].includes(arg)) {
|
|
2080
|
+
console.error(`usage: fedipod ${cmd} <${TOGGLES[cmd].join('|')}>`);
|
|
2081
|
+
process.exit(2);
|
|
2082
|
+
}
|
|
2083
|
+
const payload = modAct ? { id: modAct, action: modVerb }
|
|
2084
|
+
: cmd === 'review' ? { on: arg === 'on' }
|
|
2085
|
+
: cmd === 'joins' ? { approve: arg === 'approve' }
|
|
2086
|
+
: admitAll ? { all: true }
|
|
2087
|
+
: BY_ACTOR.includes(cmd) ? { actor: arg } : { noteId: arg };
|
|
2088
|
+
let body;
|
|
2089
|
+
try {
|
|
2090
|
+
const asPost = post || !!modAct;
|
|
2091
|
+
const res = await localFetch(HOME, PORT, `/${cmd}`, asPost
|
|
2092
|
+
? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }
|
|
2093
|
+
: undefined);
|
|
2094
|
+
body = await res.json();
|
|
2095
|
+
if (res.status === 404 && body.error === 'not a group') {
|
|
2096
|
+
console.error(`the agent on :${PORT} is not a group — these commands only apply to one`);
|
|
2097
|
+
process.exit(2);
|
|
2098
|
+
}
|
|
2099
|
+
if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
|
|
2100
|
+
} catch (e) {
|
|
2101
|
+
console.error(`agent not reachable on :${PORT} (${e.message})`);
|
|
2102
|
+
process.exit(1);
|
|
2103
|
+
}
|
|
2104
|
+
if (cmd === 'modqueue' && modAct) {
|
|
2105
|
+
console.log(`${body.action === 'apply' ? 'applied' : 'dismissed'}: ${body.type} from ${body.moderator}`);
|
|
2106
|
+
console.log(`${body.remaining} left in the queue`);
|
|
2107
|
+
} else if (cmd === 'modqueue') {
|
|
2108
|
+
// Moderation asked for over federation. A delivery does not prove who sent
|
|
2109
|
+
// it, so a moderator's request waits here instead of acting on arrival.
|
|
2110
|
+
const q = Array.isArray(body) ? body : (body.queue || []);
|
|
2111
|
+
if (!q.length) console.log('nothing waiting — no moderator has asked for anything');
|
|
2112
|
+
for (const e of q) console.log(`${e.id} ${e.at} ${e.type} from ${e.moderator}`);
|
|
2113
|
+
if (q.length) {
|
|
2114
|
+
console.log('\ncarry one out: fedipod modqueue --apply <id>');
|
|
2115
|
+
console.log('turn one down: fedipod modqueue --dismiss <id>');
|
|
2116
|
+
}
|
|
2117
|
+
} else if (cmd === 'members') {
|
|
2118
|
+
if (!body.members.length) console.log('no members yet — nobody has followed this group');
|
|
2119
|
+
for (const m of body.members) console.log(`${m.muted ? 'muted ' : ' '}${m.actor}`);
|
|
2120
|
+
console.log('\nstop carrying someone: fedipod mute <actor-url> (undo: unmute)');
|
|
2121
|
+
console.log('remove them entirely: fedipod eject <actor-url>');
|
|
2122
|
+
} else if (cmd === 'announced') {
|
|
2123
|
+
if (!body.announced.length) console.log('nothing carried yet');
|
|
2124
|
+
for (const a of body.announced) console.log(`${a.announcedAt} ${a.actor} ${a.noteId}`);
|
|
2125
|
+
console.log('\nunsay one: fedipod retract <note-url>');
|
|
2126
|
+
} else if (cmd === 'pending') {
|
|
2127
|
+
console.log(`review is ${body.review ? 'ON' : 'off'}`);
|
|
2128
|
+
if (!body.pending.length) console.log('nothing held');
|
|
2129
|
+
for (const q of body.pending) console.log(`${q.at} ${q.actor} ${q.noteId}`);
|
|
2130
|
+
if (body.pending.length) console.log('\nfedipod approve <note-url> (or decline)');
|
|
2131
|
+
} else if (cmd === 'requests') {
|
|
2132
|
+
console.log(`joins ${body.approveJoins ? 'need approval' : 'are open — anyone can join'}`);
|
|
2133
|
+
if (!body.requests.length) console.log('nobody waiting');
|
|
2134
|
+
for (const q of body.requests) console.log(`${q.at} ${q.actor}`);
|
|
2135
|
+
if (body.requests.length) console.log('\nfedipod admit <actor-url> (or refuse)');
|
|
2136
|
+
} else if (cmd === 'joins') {
|
|
2137
|
+
console.log(body.approveJoins
|
|
2138
|
+
? 'joins now need approval — the actor advertises manuallyApprovesFollowers and was republished'
|
|
2139
|
+
: 'joins are now open — anyone who follows is admitted at once');
|
|
2140
|
+
} else if (cmd === 'admit' || cmd === 'refuse') {
|
|
2141
|
+
if (admitAll) console.log(`admitted ${body.admitted} — ${body.requests} still waiting`);
|
|
2142
|
+
else console.log(`${cmd === 'admit' ? 'admitted' : 'refused'} ${arg} — ${body.requests} still waiting`);
|
|
2143
|
+
} else if (cmd === 'mute' || cmd === 'unmute') {
|
|
2144
|
+
console.log(`${cmd}d ${arg} — ${body.actors.length} muted member(s)`);
|
|
2145
|
+
} else if (cmd === 'eject') {
|
|
2146
|
+
console.log(`ejected ${arg}${body.told ? ' — their server was told' : ' (no inbox on record; not told)'}`);
|
|
2147
|
+
console.log('they are muted too, so a re-follow rejoins but nothing of theirs is carried');
|
|
2148
|
+
} else if (cmd === 'retract') {
|
|
2149
|
+
console.log(`retracted ${arg} — Undo sent to ${body.inboxes} inbox(es)`);
|
|
2150
|
+
} else if (cmd === 'review') {
|
|
2151
|
+
console.log(`review is now ${body.review ? 'ON — nothing is carried until approved' : 'off'}`);
|
|
2152
|
+
} else {
|
|
2153
|
+
console.log(`${cmd}d ${arg} — ${body.pending} still held`);
|
|
2154
|
+
}
|
|
2155
|
+
} else if (cmd === 'archive') {
|
|
2156
|
+
// Whether drained mail's original bytes are kept in the private half's
|
|
2157
|
+
// inbox-archive/. On by default; served by the running agent.
|
|
2158
|
+
requireIdentity();
|
|
2159
|
+
if (!['on', 'off'].includes(args[1])) {
|
|
2160
|
+
console.error('usage: fedipod archive <on|off>');
|
|
2161
|
+
process.exit(2);
|
|
2162
|
+
}
|
|
2163
|
+
try {
|
|
2164
|
+
const res = await localFetch(HOME, PORT, `/archive`, {
|
|
2165
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
2166
|
+
body: JSON.stringify({ on: args[1] === 'on' }),
|
|
2167
|
+
});
|
|
2168
|
+
const body = await res.json();
|
|
2169
|
+
if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
|
|
2170
|
+
console.log(body.archiveInbox
|
|
2171
|
+
? 'inbox archive is on — drained mail keeps its original bytes in the private half'
|
|
2172
|
+
: 'inbox archive is off — drained mail leaves no copy behind');
|
|
2173
|
+
} catch (e) {
|
|
2174
|
+
console.error(`agent not reachable on :${PORT} (${e.message})`);
|
|
2175
|
+
process.exit(1);
|
|
2176
|
+
}
|
|
2177
|
+
} else if (cmd === 'export') {
|
|
2178
|
+
// The account's collections as paged Turtle AS2 collections,
|
|
2179
|
+
// produced on demand: outbox and followers from the pod, inbox from the
|
|
2180
|
+
// local archive. See lib/export-collections.mjs.
|
|
2181
|
+
requireIdentity();
|
|
2182
|
+
const format = flag('format') || args[1];
|
|
2183
|
+
const to = flag('to');
|
|
2184
|
+
if (format !== 'as-collections' || !to) {
|
|
2185
|
+
console.error('usage: fedipod export --format as-collections --to <directory-or-container-url>');
|
|
2186
|
+
process.exit(2);
|
|
2187
|
+
}
|
|
2188
|
+
const { Agent } = await import(new URL('../run-agent.mjs', import.meta.url));
|
|
2189
|
+
const { RemotePod } = await import(new URL('../lib/remote.mjs', import.meta.url));
|
|
2190
|
+
const { apUrls } = await import(new URL('../lib/wire.mjs', import.meta.url));
|
|
2191
|
+
const { exportCollections, DIR_BASE } = await import(new URL('../lib/export-collections.mjs', import.meta.url));
|
|
2192
|
+
const { storageFor } = await import(new URL('../lib/storage.mjs', import.meta.url));
|
|
2193
|
+
const agent = new Agent({ home: HOME, log: () => {} });
|
|
2194
|
+
const cred = agent.readCredential();
|
|
2195
|
+
if (!cred) { console.error('no credential — run setup first'); process.exit(2); }
|
|
2196
|
+
agent.remote = new RemotePod(cred);
|
|
2197
|
+
await agent.remote.warmup();
|
|
2198
|
+
agent.urls = apUrls(cred.remotePod, cred.root);
|
|
2199
|
+
agent.store.attach(agent.privateStorage(cred, 'state'));
|
|
2200
|
+
await agent.store.load();
|
|
2201
|
+
const { Publisher } = await import(new URL('../lib/publisher.mjs', import.meta.url));
|
|
2202
|
+
const outboxItems = (await Publisher.prototype.readPublishedOutbox.call(
|
|
2203
|
+
{ remote: agent.remote, urls: agent.urls })) || [];
|
|
2204
|
+
// Only real AP actors, like the published followers collection.
|
|
2205
|
+
const contacts = agent.store.getContacts();
|
|
2206
|
+
const followers = contacts.followers.filter(f => !f.bsky).map(f => f.actor);
|
|
2207
|
+
const archive = agent.privateStorage(cred, 'archive');
|
|
2208
|
+
const inboxEntries = [];
|
|
2209
|
+
const months = (await archive.list('').catch(() => ({ names: [] }))).names || [];
|
|
2210
|
+
for (const m of months.filter(n => n.endsWith('/'))) {
|
|
2211
|
+
const files = (await archive.list(m).catch(() => ({ names: [] }))).names || [];
|
|
2212
|
+
for (const f of files) {
|
|
2213
|
+
const r = await archive.read(m + f);
|
|
2214
|
+
if (!r.ok) continue;
|
|
2215
|
+
try { inboxEntries.push(JSON.parse(r.body)); } catch { /* not an archive record */ }
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
const isUrl = /^https?:/i.test(to);
|
|
2219
|
+
const storage = storageFor(to, (u, i) => agent.remote.fetch(u, i));
|
|
2220
|
+
const base = isUrl ? (to.endsWith('/') ? to : to + '/') : DIR_BASE;
|
|
2221
|
+
try {
|
|
2222
|
+
const out = await exportCollections({
|
|
2223
|
+
outboxItems, followers, inboxEntries, storage, base, log: console.log,
|
|
2224
|
+
resolve: (u) => agent.remote.getJson(u),
|
|
2225
|
+
});
|
|
2226
|
+
console.log(`outbox: ${out.outbox.items} item(s) in ${out.outbox.pages} page(s); `
|
|
2227
|
+
+ `followers: ${out.followers.items}; inbox: ${out.inbox.items} archived item(s) in ${out.inbox.pages} page(s)`);
|
|
2228
|
+
console.log(`${out.written} Turtle document(s) written to ${to}`);
|
|
2229
|
+
} catch (e) {
|
|
2230
|
+
console.error(`export failed: ${e.message}`);
|
|
2231
|
+
process.exit(1);
|
|
2232
|
+
}
|
|
2233
|
+
} else if (cmd === 'bsky') {
|
|
2234
|
+
// Bluesky account commands, served by the running agent's admin API.
|
|
2235
|
+
const sub = args[1];
|
|
2236
|
+
let out;
|
|
2237
|
+
try {
|
|
2238
|
+
if (sub === 'connect') {
|
|
2239
|
+
const identifier = flag('handle') || args[2];
|
|
2240
|
+
const appPassword = flag('app-password') || args[3];
|
|
2241
|
+
if (!identifier || !appPassword) {
|
|
2242
|
+
console.error('usage: fedipod bsky connect <handle> <app-password> [--service https://bsky.social]');
|
|
2243
|
+
process.exit(2);
|
|
2244
|
+
}
|
|
2245
|
+
const res = await localFetch(HOME, PORT, `/atproto/connect`, {
|
|
2246
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
2247
|
+
body: JSON.stringify({ identifier, appPassword, service: flag('service') }),
|
|
2248
|
+
});
|
|
2249
|
+
out = await res.json();
|
|
2250
|
+
if (res.status >= 400) { console.error(out.error || `HTTP ${res.status}`); process.exit(1); }
|
|
2251
|
+
console.log(`connected: @${out.handle} on ${out.service}`);
|
|
2252
|
+
console.log('public posts will cross-post; turn off: fedipod bsky crosspost off');
|
|
2253
|
+
} else if (sub === 'disconnect') {
|
|
2254
|
+
const res = await localFetch(HOME, PORT, `/atproto/disconnect`, {
|
|
2255
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
|
|
2256
|
+
});
|
|
2257
|
+
out = await res.json();
|
|
2258
|
+
if (res.status >= 400) { console.error(out.error || `HTTP ${res.status}`); process.exit(1); }
|
|
2259
|
+
console.log('disconnected — the local credential is gone');
|
|
2260
|
+
} else if (sub === 'crosspost') {
|
|
2261
|
+
if (!['on', 'off'].includes(args[2])) {
|
|
2262
|
+
console.error('usage: fedipod bsky crosspost <on|off>');
|
|
2263
|
+
process.exit(2);
|
|
2264
|
+
}
|
|
2265
|
+
const res = await localFetch(HOME, PORT, `/atproto`, {
|
|
2266
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
2267
|
+
body: JSON.stringify({ crossPost: args[2] === 'on' }),
|
|
2268
|
+
});
|
|
2269
|
+
out = await res.json();
|
|
2270
|
+
if (res.status >= 400) { console.error(out.error || `HTTP ${res.status}`); process.exit(1); }
|
|
2271
|
+
console.log(`cross-posting is ${out.atproto.crossPost ? 'on' : 'off'}`);
|
|
2272
|
+
} else {
|
|
2273
|
+
const res = await localFetch(HOME, PORT, `/status`);
|
|
2274
|
+
out = await res.json();
|
|
2275
|
+
const a = out.atproto;
|
|
2276
|
+
if (!a?.connected) console.log('no bluesky account connected — fedipod bsky connect <handle> <app-password>');
|
|
2277
|
+
else console.log(`@${a.handle} (${a.did}) on ${a.service}${a.lastError ? `\nlast error: ${a.lastError}` : ''}`);
|
|
2278
|
+
}
|
|
2279
|
+
} catch (e) {
|
|
2280
|
+
console.error(`agent not reachable on :${PORT} (${e.message})`);
|
|
2281
|
+
process.exit(1);
|
|
2282
|
+
}
|
|
2283
|
+
} else {
|
|
2284
|
+
console.log('usage: fedipod <setup|start|stop|status|state|upgrade|rebuild|home|passwd'
|
|
2285
|
+
+ '|tokens|revoke-credential|install-service|archive|alias|import|keys|front> [--flags]');
|
|
2286
|
+
console.log(' keys: where the signing key lives; --to pod|local moves it (pod = multi-device signing)');
|
|
2287
|
+
console.log(' https: the local certificate agents serve TLS with; --trust mints a local CA for strict clients');
|
|
2288
|
+
console.log(' gateway: attach to a gateway; --inbox-only keeps your identity and moves only the inbox;');
|
|
2289
|
+
console.log(' --detach returns delivery to your pod (\'front\' still works as an alias)');
|
|
2290
|
+
console.log(' alias: --add <@you@old.server|url> | --remove <url> [--yes] migration aliases (alsoKnownAs)');
|
|
2291
|
+
console.log(' import: <csv-file…> from the old account\'s export (follows, blocks, mutes, lists,');
|
|
2292
|
+
console.log(' domain blocks); no files = progress; --clear drops the record');
|
|
2293
|
+
console.log(' state: --to <path|url|pod> move THIS identity\'s private half');
|
|
2294
|
+
console.log(' --all [--apply] move every identity\'s onto this machine');
|
|
2295
|
+
console.log(' --drop-remote [--apply] remove the pod\'s copy afterwards');
|
|
2296
|
+
console.log(' upgrade: what every identity here is behind on, and stamp the ones that are not');
|
|
2297
|
+
console.log(' bsky: connect <handle> <app-password> | disconnect | crosspost <on|off> | status');
|
|
2298
|
+
console.log(' group: members | eject <actor> | mute <actor> | unmute <actor>');
|
|
2299
|
+
console.log(' joins <open|approve> | requests | admit <actor> | refuse <actor>');
|
|
2300
|
+
console.log(' announced | retract <note> | review <on|off> | pending | approve <note> | decline <note>');
|
|
2301
|
+
console.log(' modqueue | modqueue --apply <id> | modqueue --dismiss <id>');
|
|
2302
|
+
process.exit(cmd ? 2 : 0);
|
|
2303
|
+
}
|