diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..a53f410
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,44 @@
+# MainWP Control CLI — Environment Variables
+# Copy to .env or export in your shell profile.
+
+# ─── Dashboard Credentials ───────────────────────────────────────────
+# Application password for Dashboard authentication.
+# Required when OS keychain (keytar) is unavailable (CI, containers).
+# MAINWP_APP_PASSWORD=
+
+# Set to 1 to skip loading keytar (OS keychain). Useful in CI/containers
+# where native modules are unavailable. Falls back to MAINWP_APP_PASSWORD.
+# MAINWPCTL_NO_KEYTAR=1
+
+# ─── Network ─────────────────────────────────────────────────────────
+# Allow insecure HTTP connections (not recommended for production).
+# MAINWP_ALLOW_HTTP=1
+
+# ─── LLM Provider (for chat mode) ────────────────────────────────────
+# Provider name: openai, anthropic, gemini, openrouter, local
+# MAINWP_LLM_PROVIDER=
+
+# Model override (e.g., gpt-4o, claude-sonnet-4-20250514, gemini-pro)
+# MAINWP_LLM_MODEL=
+
+# Generic LLM API key (provider auto-detected). Prefer provider-specific vars below.
+# MAINWP_LLM_API_KEY=
+
+# Provider-specific API keys (set the one for your provider):
+# OPENAI_API_KEY=
+# ANTHROPIC_API_KEY=
+# GOOGLE_API_KEY=
+# OPENROUTER_API_KEY=
+# LOCAL_LLM_API_KEY=
+# LOCAL_LLM_URL=http://localhost:11434/v1
+
+# ─── Output & Debug ──────────────────────────────────────────────────
+# Disable colored output (https://no-color.org/)
+# NO_COLOR=1
+
+# Enable debug output
+# DEBUG=1
+
+# ─── Paths ───────────────────────────────────────────────────────────
+# Override config directory (default: ~/.config/mainwpctl)
+# XDG_CONFIG_HOME=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 83a2c52..be2e255 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,7 +11,7 @@ jobs:
strategy:
matrix:
node: [20, 22]
- os: [ubuntu-latest, macos-latest]
+ os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
@@ -20,6 +20,9 @@ jobs:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
+ - run: npm audit --omit=dev
+ continue-on-error: true
- run: npm run lint
- run: npm run typecheck
+ - run: npm run build
- run: npm test
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
index c2c35c4..c630ef3 100644
--- a/README.md
+++ b/README.md
@@ -1,67 +1,139 @@
# MainWP Control
-Automation and AI workflows for the MainWP Dashboard. The CLI command is `mainwpctl`.
+A CLI for managing your MainWP Dashboard from the terminal. The command is `mainwpctl`.
-### What You Can Do
-
-- **Site Management**: List sites, check status, sync data, add or remove child sites
-- **Update Management**: Preview and apply core, plugin, and theme updates across sites
-- **Batch Operations**: Run updates, sync, or reconnect across dozens of sites with `--wait`
-- **CI/CD Integration**: Deterministic exit codes, JSON output, and composability with Unix tools
-- **Interactive Chat**: Explore abilities through natural conversation (optional, requires LLM key)
-
-Built for WordPress agencies and site managers who automate their MainWP workflows.
+You can list sites, check status, push updates, sync data, add or remove child sites, and run batch operations across dozens of sites. Everything outputs structured JSON for piping into other tools. Exit codes are deterministic so CI pipelines can branch on them. There's also an optional chat mode if you want to explore abilities conversationally before scripting them.
---
## When to Use MainWP Control vs MCP Server
-Use the **[MainWP MCP Server](https://github.com/mainwp/mainwp-mcp)** for conversational AI management — natural language queries inside Claude, Cursor, ChatGPT, or any MCP-compatible client. The MCP server excels at exploration, ad-hoc questions, and interactive workflows.
+**[MainWP MCP Server](https://github.com/mainwp/mainwp-mcp)** is for conversational AI management: natural language queries inside Claude, Cursor, ChatGPT, or any MCP-compatible client. Good for exploration, ad-hoc questions, and interactive workflows.
-Use **MainWP Control** for automated and scripted workflows — cron jobs, CI/CD pipelines, monitoring scripts, and batch operations. `mainwpctl` gives you deterministic exit codes, stable JSON output, and composability with standard Unix tools. Both talk to the same Abilities API with the same safety model.
+**MainWP Control** is for automation: cron jobs, CI/CD pipelines, monitoring scripts, and batch operations. `mainwpctl` gives you deterministic exit codes, stable JSON output, and composability with standard Unix tools. Both talk to the same Abilities API with the same safety model.
---
## Quick Start
-**Requirements:** Node.js >=20 and MainWP Dashboard 6+ with Abilities API
+### Prerequisites
+
+1. **Node.js 20 or later** (the LTS version from [nodejs.org](https://nodejs.org/) is recommended)
+2. **A MainWP Dashboard** (version 6+) with the Abilities API enabled
+3. **A WordPress Application Password** (not your login password). Create one in WordPress admin under Users > Your Profile > Application Passwords. See the [WordPress Application Passwords guide](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/) for details.
+
+### Option A: Standard install (recommended)
+
+This is the best path for most users. Pre-built keychain binaries are included for macOS, Windows, and Linux (x64 and arm64). On other platforms, you may need C++ build tools during installation.
```bash
-# Install
-npm install -g mainwpctl
+# Install globally
+npm install -g @mainwp/control
-# Authenticate
+# Log in (stores credentials in your OS keychain)
mainwpctl login
-# List sites (JSON for scripting)
+# Verify it works
mainwpctl abilities run list-sites-v1 --json
```
+### Option B: Environment variable auth (no keychain)
+
+Use this if keytar fails to build, or in CI, Docker, and headless environments where no OS keychain is available.
+
+```bash
+# Install globally
+npm install -g @mainwp/control
+
+# Set your Application Password as an env var
+export MAINWP_APP_PASSWORD='xxxx xxxx xxxx xxxx xxxx xxxx'
+
+# Log in non-interactively (credentials stay in the environment, not on disk)
+mainwpctl login --url https://dashboard.example.com --username admin
+
+# Verify it works
+mainwpctl abilities run list-sites-v1 --json
+```
+
+> **Tip:** If keytar is installed but broken, set `MAINWPCTL_NO_KEYTAR=1` to skip loading it entirely.
+
+When the OS keychain is unavailable, `mainwpctl` does not persist plaintext credentials. Keep `MAINWP_APP_PASSWORD` set for each run on CI, cron hosts, and headless servers.
+
+---
+
+
+New to the Command Line?
+
+If you haven't used a terminal before, here's what you need to know.
+
+### What is a terminal?
+
+A terminal is where you type commands instead of clicking buttons. You'll see it called "command line" or "shell" in different places.
+
+**How to open it:**
+- **macOS**: Open **Terminal** (search in Spotlight, or look in Applications > Utilities)
+- **Windows**: Open **PowerShell** (search in the Start menu)
+- **Linux**: Open your distribution's **Terminal** app (usually in the applications menu)
+
+### What does `npm install -g` do?
+
+`npm` is the Node.js package manager. It downloads and installs JavaScript packages. The `-g` flag installs globally, which makes `mainwpctl` available as a command anywhere on your system, not only in one project folder.
+
+### What is an environment variable?
+
+An environment variable is a named value that programs can read. They're commonly used for passwords and API keys.
+
+**Setting one:**
+```bash
+# macOS / Linux (lasts until you close the terminal)
+export MAINWP_APP_PASSWORD='xxxx xxxx xxxx xxxx xxxx xxxx'
+
+# Windows PowerShell (lasts until you close the window)
+$env:MAINWP_APP_PASSWORD = 'xxxx xxxx xxxx xxxx xxxx xxxx'
+```
+
+For long-term storage, use the OS keychain (the default when you run `mainwpctl login`) or a restricted-permission `.env` file rather than pasting credentials into shell profile files.
+
+### What is an Application Password?
+
+WordPress Application Passwords let external tools like `mainwpctl` access your site without using your main login password. They look like groups of four characters separated by spaces (e.g., `abcd efgh ijkl mnop qrst uvwx`).
+
+**To create one:** Log into WordPress admin > Users > Your Profile > scroll to **Application Passwords** > enter a name like "mainwpctl" > click **Add New Application Password** > copy the generated password.
+
+### Reading command output
+
+When you run a command, the output appears in your terminal. A few things to know:
+
+- **`--json`** tells `mainwpctl` to output structured JSON (useful for scripting and piping to other tools)
+- **Exit codes** indicate success (`0`) or failure (`1` through `5`). You won't see them directly, but scripts and CI use them to decide what happens next. Run `echo $?` (macOS/Linux) or `echo $LASTEXITCODE` (PowerShell) after a command to check.
+
+
+
---
## Real-World Workflows
-Destructive operations in MainWP Control follow a safe two-step pattern — preview first, then execute:
+We recommend a two-step pattern for destructive operations: preview first, then execute.
```bash
-# Step 1: Preview what will be deleted (nothing is modified)
+# Step 1: Preview what will be deleted (nothing changes)
mainwpctl abilities run delete-site-v1 \
--input '{"site_id_or_domain": "mysite.com"}' \
--dry-run --json
-# Step 2: Apply after reviewing the preview
+# Step 2: Execute after reviewing the preview
mainwpctl abilities run delete-site-v1 \
--input '{"site_id_or_domain": "mysite.com"}' \
--confirm --force --json
```
-Each workflow guide below is fully standalone — it walks you from creating an Application Password through a working result, with every step verified and every concept explained.
+Each workflow guide below walks you from creating an Application Password through a working result, with every step verified.
| Workflow | Description |
|----------|-------------|
| [Daily Health Check](docs/workflows/daily-health-check.md) | Cron job that checks site connectivity and alerts via Slack |
| [Plugin Deployment Verification](docs/workflows/plugin-deployment-verification.md) | GitHub Actions workflow to verify a plugin exists across all sites |
-| [Monthly Batch Updates](docs/workflows/monthly-batch-updates.md) | Preview and apply updates safely — scripted and GitHub Actions variants |
+| [Monthly Batch Updates](docs/workflows/monthly-batch-updates.md) | Preview and apply updates safely, scripted and GitHub Actions variants |
| [Input from File](docs/workflows/input-from-file.md) | Pass complex parameters via JSON files, stdin pipes, or heredocs |
| [Monitoring Integration](docs/workflows/monitoring-integration.md) | Send site metrics to Datadog, StatsD, or other monitoring tools |
@@ -125,15 +197,17 @@ mainwpctl profile use
### Authentication
```bash
-# Interactive login
+# Interactive login (stores credentials in OS keychain)
mainwpctl login
-# Non-interactive login (CI)
+# Non-interactive login for CI/containers (password from env var)
export MAINWP_APP_PASSWORD='your-application-password'
mainwpctl login --url https://dashboard.example.com --username admin
```
-When the OS keychain is unavailable, `mainwpctl` does not persist plaintext credentials. Keep `MAINWP_APP_PASSWORD` available to each non-interactive run on CI, cron hosts, and headless servers.
+To create an Application Password: log into WordPress admin > Users > Your Profile > Application Passwords > add a new password named "mainwpctl".
+
+When the OS keychain is unavailable, `mainwpctl` does not persist plaintext credentials. Keep `MAINWP_APP_PASSWORD` set for each non-interactive run on CI, cron hosts, and headless servers.
### Chat Mode
@@ -148,39 +222,21 @@ mainwpctl chat "list all sites with pending updates"
```
Chat requires one of these environment variables:
-- `ANTHROPIC_API_KEY` — Anthropic Claude
-- `OPENAI_API_KEY` — OpenAI GPT
-- `GOOGLE_API_KEY` — Google Gemini
-- `OPENROUTER_API_KEY` — OpenRouter
-- `LOCAL_LLM_API_KEY` — Local endpoint (with optional `LOCAL_LLM_URL`)
+- `ANTHROPIC_API_KEY` (Anthropic Claude)
+- `OPENAI_API_KEY` (OpenAI GPT)
+- `GOOGLE_API_KEY` (Google Gemini)
+- `OPENROUTER_API_KEY` (OpenRouter)
+- `LOCAL_LLM_API_KEY` (Local endpoint, with optional `LOCAL_LLM_URL`)
-Note: In non-TTY environments (pipes, CI), `mainwpctl chat` without a message exits with guidance. Use `mainwpctl chat "message"` for single-message mode in scripts.
+In non-TTY environments (pipes, CI), `mainwpctl chat` without a message exits with guidance. Use `mainwpctl chat "message"` for single-message mode in scripts.
---
## Safety Model
-Destructive operations follow a two-step pattern:
-
-1. **Preview** with `--dry-run` to see what will change
-2. **Execute** with `--confirm` after reviewing the preview
-
-In CI/scripted workflows, you can pass `--confirm --force` directly if you've
-already validated the operation.
+Destructive operations support a two-step workflow: preview with `--dry-run`, then execute with `--confirm`. Server-side confirmation enforcement is handled by the Abilities REST API. See the [Real-World Workflows](#real-world-workflows) section above for examples.
-### Example: Deleting a Site
-
-```bash
-# Step 1: Preview what will be deleted
-mainwpctl abilities run delete-site-v1 \
- --input '{"site_id_or_domain": "mysite.com"}' \
- --dry-run
-
-# Step 2: Confirm deletion
-mainwpctl abilities run delete-site-v1 \
- --input '{"site_id_or_domain": "mysite.com"}' \
- --confirm
-```
+In CI/scripted workflows, you can pass `--confirm --force` directly if you've already validated the operation.
---
@@ -227,7 +283,8 @@ mainwpctl abilities run delete-site-v1 \
| Variable | Description |
|----------|-------------|
-| `MAINWP_APP_PASSWORD` | Application password for non-interactive login and for commands when keychain storage is unavailable |
+| `MAINWP_APP_PASSWORD` | Application password for non-interactive login and commands when keychain storage is unavailable |
+| `MAINWPCTL_NO_KEYTAR` | Set to `1` to skip keytar (keychain) loading entirely |
| `MAINWP_ALLOW_HTTP` | Set to `1` to allow insecure HTTP Dashboard URLs |
### Chat Configuration (optional)
@@ -291,6 +348,61 @@ source /path/to/mainwpctl/scripts/completions/mainwpctl.zsh
---
+## Troubleshooting
+
+
+"keytar failed to build" or native module errors during install
+
+Keytar requires native C++ compilation on some platforms. If it fails:
+
+1. **Use environment variable auth instead** (bypasses keytar entirely):
+ ```bash
+ export MAINWP_APP_PASSWORD='your-application-password'
+ mainwpctl login --url https://dashboard.example.com --username admin
+ ```
+2. **Or skip keytar explicitly** by setting `MAINWPCTL_NO_KEYTAR=1` before running commands.
+
+The pre-built binaries cover macOS, Windows, and Linux (x64/arm64). If you're on a different platform or architecture, you'll need C++ build tools (`gcc`, `g++`, `make`) or the env var approach.
+
+
+
+
+"command not found" after install
+
+This usually means your npm global bin directory isn't in your system PATH.
+
+1. **Find where npm installs global packages:**
+ ```bash
+ npm config get prefix
+ ```
+2. **Add the `bin` subdirectory to your PATH.** For example, if the prefix is `/usr/local`:
+ ```bash
+ # Add to ~/.bashrc, ~/.zshrc, or your shell profile:
+ export PATH="/usr/local/bin:$PATH"
+ ```
+3. **Restart your terminal** (or run `source ~/.zshrc` / `source ~/.bashrc`) and try again.
+
+On Windows, the npm global directory is usually already in PATH after installing Node.js.
+
+
+
+
+"connection refused" or network errors
+
+If `mainwpctl login` or commands fail with connection errors:
+
+1. **Check the Dashboard URL.** Make sure it's the full URL with `https://` (e.g., `https://dashboard.example.com`). Don't include a trailing slash.
+2. **Verify HTTPS.** `mainwpctl` requires HTTPS by default. If your Dashboard uses HTTP (not recommended), set `MAINWP_ALLOW_HTTP=1`.
+3. **Check firewall/network.** Make sure your machine can reach the Dashboard:
+ ```bash
+ curl -I https://dashboard.example.com
+ ```
+4. **SSL certificate issues.** If using a self-signed certificate, you can use `mainwpctl login --skip-ssl-verify` (not recommended for production).
+
+
+
+---
+
## Contributing
```bash
@@ -313,10 +425,10 @@ npm run test:live
```
The live suite includes:
-- **API tests** — login, abilities discovery, read-only execution, safety model, exit codes
-- **Workflow doc tests** — validates that every jq expression, field name, and data pipeline documented in `docs/workflows/` works against the real API
+- **API tests**: login, abilities discovery, read-only execution, safety model, exit codes
+- **Workflow doc tests**: validates that every jq expression, field name, and data pipeline documented in `docs/workflows/` works against the real API
-Live tests are safe: they only run read-only operations and `--dry-run` previews — never mutations.
+Live tests are safe: they only run read-only operations and `--dry-run` previews, never mutations.
---
diff --git a/bin/_exit.js b/bin/_exit.js
new file mode 100644
index 0000000..a91890f
--- /dev/null
+++ b/bin/_exit.js
@@ -0,0 +1,12 @@
+// Shared entrypoint setup: SIGPIPE handling and clean exit for native addons.
+
+// SIGPIPE: exit cleanly when piped to `head`, `grep -q`, etc.
+process.on('SIGPIPE', () => process.exit(0));
+
+// Force exit to prevent native addon handles (e.g. keytar) from keeping
+// the process alive. Drain stdout/stderr first to avoid truncating piped output.
+const drain = (s) => new Promise((resolve) => s.write('', resolve));
+export async function drainAndExit() {
+ await Promise.all([drain(process.stdout), drain(process.stderr)]);
+ process.exit(process.exitCode ?? 0);
+}
diff --git a/bin/dev.js b/bin/dev.js
index 2b5ae1d..e6d16e1 100755
--- a/bin/dev.js
+++ b/bin/dev.js
@@ -1,5 +1,7 @@
#!/usr/bin/env node
+import { drainAndExit } from './_exit.js';
import { execute } from '@oclif/core';
await execute({ development: true, dir: import.meta.url });
+await drainAndExit();
diff --git a/bin/run.js b/bin/run.js
index 176d2af..7c0db5d 100755
--- a/bin/run.js
+++ b/bin/run.js
@@ -1,5 +1,7 @@
#!/usr/bin/env node
+import { drainAndExit } from './_exit.js';
import { execute } from '@oclif/core';
await execute({ dir: import.meta.url });
+await drainAndExit();
diff --git a/docs/workflows/daily-health-check.md b/docs/workflows/daily-health-check.md
index 60cfbe6..9ee28ed 100644
--- a/docs/workflows/daily-health-check.md
+++ b/docs/workflows/daily-health-check.md
@@ -2,7 +2,7 @@
> Automatically check your MainWP sites every day and get a Slack alert when something goes wrong.
-Managing a network of WordPress sites means things can break silently — a site goes offline, a connection drops, a server becomes unreachable. This guide walks you through setting up a fully automated daily check that monitors every site connected to your MainWP Dashboard and sends you a Slack notification when something needs attention.
+Managing a network of WordPress sites means things can break silently. A site goes offline, a connection drops, a server becomes unreachable. This guide walks you through setting up a fully automated daily check that monitors every site connected to your MainWP Dashboard and sends you a Slack notification when something needs attention.
You do not need any prior experience with scripting, cron jobs, or the command line. Every concept is explained before it is used.
@@ -14,7 +14,7 @@ By the end of this guide you will have:
- **A bash script** that checks all your MainWP-connected sites for connectivity issues
- **Slack notifications** when any site is disconnected or unreachable
-- **A cron job** that runs this check automatically every day without any manual intervention
+- **A cron job** that runs this check automatically every day
---
@@ -24,7 +24,7 @@ Before you begin, make sure you have the following:
- **MainWP Dashboard 6 or later**, installed and running on a WordPress site. This is the central hub that manages your connected sites.
- **WordPress admin access** to your Dashboard site. You will need to create an Application Password for CLI authentication.
-- **Node.js 20 or later** installed on the machine that will run MainWP Control. This is the machine where the daily health check will execute — it does not need to be the same server as your Dashboard.
+- **Node.js 20 or later** installed on the machine that will run MainWP Control. This is the machine where the daily health check will execute. It does not need to be the same server as your Dashboard.
---
@@ -33,9 +33,9 @@ Before you begin, make sure you have the following:
Application Passwords are a WordPress feature that lets external tools (like MainWP Control) authenticate with your site without using your main login credentials. Each Application Password is a separate credential you can revoke independently.
1. Log in to your WordPress Dashboard site as an administrator.
-2. Navigate to **Users → Your Profile** (or click your name in the top-right corner and select "Edit Profile").
+2. Navigate to **Users > Your Profile** (or click your name in the top-right corner and select "Edit Profile").
3. Scroll down to the **Application Passwords** section near the bottom of the page.
-4. In the **"New Application Password Name"** field, type a name to identify this credential — for example, `mainwpctl`.
+4. In the **"New Application Password Name"** field, type a name to identify this credential, for example `mainwpctl`.
5. Click **"Add New Application Password"**.
6. WordPress will display a generated password. It looks something like this:
@@ -44,7 +44,7 @@ Application Passwords are a WordPress feature that lets external tools (like Mai
```
7. **Copy this password immediately.** WordPress will not show it again. If you lose it, you will need to create a new one.
-8. Store it somewhere secure — a password manager, a CI/CD secret store, or a secure note. You will need it in Step 3.
+8. Store it somewhere secure: a password manager, a CI/CD secret store, or a secure note. You will need it in Step 3.
---
@@ -59,7 +59,7 @@ MainWP Control is a command-line tool distributed as an npm package. npm is the
A global install makes `mainwpctl` available as a command anywhere on your system:
```bash
-npm install -g mainwpctl
+npm install -g @mainwp/control
```
### Option B: Run without installing (npx)
@@ -75,7 +75,7 @@ npx mainwpctl
If you are integrating MainWP Control into an existing project:
```bash
-npm install mainwpctl
+npm install @mainwp/control
```
Then run it with `npx mainwpctl` from that project directory.
@@ -110,9 +110,9 @@ mainwpctl login
You will be prompted for three pieces of information:
-1. **Dashboard URL** — The full URL of your MainWP Dashboard site (e.g., `https://manage.example.com`).
-2. **Username** — Your WordPress admin username on the Dashboard site.
-3. **Application Password** — The password you created in Step 1. Paste it in when prompted (the spaces in the password are fine — include them or omit them, both work).
+1. **Dashboard URL:** The full URL of your MainWP Dashboard site (e.g., `https://manage.example.com`).
+2. **Username:** Your WordPress admin username on the Dashboard site.
+3. **Application Password:** The password you created in Step 1. Paste it in when prompted. The spaces in the password are fine; include them or omit them, both work.
After entering these, MainWP Control stores your credentials in your system's keychain when one is available (macOS Keychain, Linux secret service, or Windows Credential Manager). If the machine cannot use a keychain, keep `MAINWP_APP_PASSWORD` available in the environment for future runs.
@@ -204,7 +204,7 @@ If you see an error or the message does not appear, double-check that you copied
A **bash script** is a plain text file containing a sequence of commands that your computer executes one after another. Instead of typing commands manually each time, you write them in a file and run that file.
-We will build the script step by step, adding one capability at a time. Create a new file called `mainwp-health-check.sh` in a location you will remember — your home directory or a scripts folder works well.
+We will build the script step by step, adding one capability at a time. Create a new file called `mainwp-health-check.sh` in a location you will remember. Your home directory or a scripts folder works well.
You can create the file using any text editor. If you are unsure, use `nano` in your terminal:
@@ -218,7 +218,7 @@ Enter the following content:
```bash
#!/bin/bash
-# mainwp-health-check.sh — Check MainWP site connectivity
+# mainwp-health-check.sh - Check MainWP site connectivity
RESULT=$(mainwpctl abilities run list-sites-v1 --json 2>/dev/null)
echo "$RESULT"
@@ -226,10 +226,10 @@ echo "$RESULT"
Here is what each line does:
-- `#!/bin/bash` — This is called a **shebang**. It tells your operating system which program to use to run this script. `/bin/bash` is the Bash shell, which is available on macOS and virtually all Linux systems.
-- `# mainwp-health-check.sh ...` — Lines starting with `#` are **comments**. They are ignored when the script runs and exist only to help humans understand the code.
-- `RESULT=$(mainwpctl abilities run list-sites-v1 --json 2>/dev/null)` — This runs the MainWP Control command that lists all sites, captures its output into a **variable** called `RESULT`. The `--json` flag tells MainWP Control to output structured JSON data instead of human-readable text. The `2>/dev/null` part hides any warning messages so only the JSON output is captured.
-- `echo "$RESULT"` — This prints the captured output to the terminal so you can see it.
+- `#!/bin/bash`: This is called a **shebang**. It tells your operating system which program to use to run this script. `/bin/bash` is the Bash shell, which is available on macOS and virtually all Linux systems.
+- `# mainwp-health-check.sh ...`: Lines starting with `#` are **comments**. They are ignored when the script runs and exist only to help humans understand the code.
+- `RESULT=$(mainwpctl abilities run list-sites-v1 --json 2>/dev/null)`: This runs the MainWP Control command that lists all sites, captures its output into a **variable** called `RESULT`. The `--json` flag tells MainWP Control to output structured JSON data instead of human-readable text. The `2>/dev/null` part hides any warning messages so only the JSON output is captured.
+- `echo "$RESULT"`: This prints the captured output to the terminal so you can see it.
Save the file (in nano: press `Ctrl+O`, then `Enter`, then `Ctrl+X` to exit).
@@ -279,7 +279,7 @@ Update your script to check whether the mainwpctl command succeeded:
```bash
#!/bin/bash
-# mainwp-health-check.sh — Check MainWP site connectivity
+# mainwp-health-check.sh - Check MainWP site connectivity
RESULT=$(mainwpctl abilities run list-sites-v1 --json 2>/dev/null)
EXIT=$?
@@ -294,11 +294,11 @@ echo "Command succeeded. Site data retrieved."
New lines explained:
-- `EXIT=$?` — Captures the exit code of the mainwpctl command into a variable called `EXIT`.
-- `if [ $EXIT -ne 0 ]; then` — This is a **conditional**. `-ne` means "not equal to". So this reads: "if the exit code is not equal to zero, then..."
-- `echo "Health check command failed with exit code $EXIT"` — Prints an error message that includes the actual exit code.
-- `exit 1` — Stops the script immediately and reports failure (exit code 1).
-- `fi` — Marks the end of the `if` block.
+- `EXIT=$?`: Captures the exit code of the mainwpctl command into a variable called `EXIT`.
+- `if [ $EXIT -ne 0 ]; then`: This is a **conditional**. `-ne` means "not equal to". So this reads: "if the exit code is not equal to zero, then..."
+- `echo "Health check command failed with exit code $EXIT"`: Prints an error message that includes the actual exit code.
+- `exit 1`: Stops the script immediately and reports failure (exit code 1).
+- `fi`: Marks the end of the `if` block.
Save and run:
@@ -320,7 +320,7 @@ Health check command failed with exit code 3
### 5c: Parse with jq
-The JSON output from MainWP Control contains structured data, but we need to extract specific information from it — namely, how many sites are not connected. For this we use **jq**, a command-line tool designed for reading and filtering JSON data.
+The JSON output from MainWP Control contains structured data, but we need to extract specific information from it, namely how many sites are not connected. For this we use **jq**, a command-line tool designed for reading and filtering JSON data.
**Install jq** if you do not already have it:
@@ -346,7 +346,7 @@ Now update the script to count disconnected sites:
```bash
#!/bin/bash
-# mainwp-health-check.sh — Check MainWP site connectivity
+# mainwp-health-check.sh - Check MainWP site connectivity
RESULT=$(mainwpctl abilities run list-sites-v1 --json 2>/dev/null)
EXIT=$?
@@ -362,11 +362,11 @@ echo "$DISCONNECTED site(s) disconnected"
The new jq line explained:
-- `echo "$RESULT"` — Sends the JSON output to jq.
-- `|` — This is a **pipe**. It takes the output of the command on the left and feeds it as input to the command on the right.
-- `.data.data.items[]` — Navigates into the JSON: start at the root, go into the outer `data` object, then the inner `data` object, then `items`, and iterate over every item in the array.
-- `select(.status != "connected")` — Keeps only the sites whose `status` field is not `"connected"`.
-- `[...] | length` — Wraps the filtered results in an array and counts how many items are in it.
+- `echo "$RESULT"`: Sends the JSON output to jq.
+- `|`: This is a **pipe**. It takes the output of the command on the left and feeds it as input to the command on the right.
+- `.data.data.items[]`: Navigates into the JSON. Start at the root, go into the outer `data` object, then the inner `data` object, then `items`, and iterate over every item in the array.
+- `select(.status != "connected")`: Keeps only the sites whose `status` field is not `"connected"`.
+- `[...] | length`: Wraps the filtered results in an array and counts how many items are in it.
- The final result is a single number: the count of disconnected sites.
Save and run:
@@ -389,11 +389,11 @@ If two sites are disconnected:
### 5d: Add Slack alerting
-Now we bring it all together — when the health check fails or finds disconnected sites, the script sends an alert to Slack. Update your script to the final version:
+Now we bring it all together. When the health check fails or finds disconnected sites, the script sends an alert to Slack. Update your script to the final version:
```bash
#!/bin/bash
-# mainwp-health-check.sh — Daily MainWP site connectivity check
+# mainwp-health-check.sh - Daily MainWP site connectivity check
# Sends a Slack alert if any sites are disconnected or if the check itself fails.
SLACK_WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
@@ -424,11 +424,11 @@ fi
What changed:
-- `SLACK_WEBHOOK_URL="..."` — Stores the webhook URL in a variable so it is easy to find and change.
+- `SLACK_WEBHOOK_URL="..."`: Stores the webhook URL in a variable so it is easy to find and change.
- The first `curl` block runs when the MainWP Control command itself fails (network issue, auth failure, etc.). It sends a Slack message with the exit code and then stops the script.
- The second `curl` block runs when disconnected sites are detected. `-gt 0` means "greater than zero". If the disconnected count is more than zero, it sends an alert.
-- `curl -s` — The `-s` flag means "silent" — it suppresses progress output from curl so it does not clutter logs.
-- The `\` at the end of a line means the command continues on the next line. This is just for readability.
+- `curl -s`: The `-s` flag means "silent." It suppresses progress output from curl so it does not clutter logs.
+- The `\` at the end of a line means the command continues on the next line. This is for readability.
- If all sites are connected, the script finishes without sending any Slack message. No news is good news.
---
@@ -475,13 +475,13 @@ to:
if [ "$DISCONNECTED" -eq 0 ]; then
```
-This inverts the condition — it alerts when zero sites are disconnected (which is the normal state). Run the script, confirm the Slack message arrives, then change the condition back to `-gt 0`.
+This inverts the condition: it alerts when zero sites are disconnected (which is the normal state). Run the script, confirm the Slack message arrives, then change the condition back to `-gt 0`.
---
## Step 8: Schedule with Cron
-**Cron** is a built-in job scheduler available on macOS and Linux. It lets you run commands automatically on a schedule — every minute, every hour, every day, or on any pattern you define. Each scheduled task is called a **cron job**.
+**Cron** is a built-in job scheduler available on macOS and Linux. It runs commands automatically on a schedule: every minute, every hour, every day, or on any pattern you define. Each scheduled task is called a **cron job**.
### Open the crontab
@@ -503,13 +503,13 @@ minute hour day-of-month month day-of-week command
| Field | Values | Meaning |
|---------------|---------|----------------------------------|
-| minute | 0–59 | Minute of the hour |
-| hour | 0–23 | Hour of the day (24-hour format) |
-| day-of-month | 1–31 | Day of the month |
-| month | 1–12 | Month of the year |
-| day-of-week | 0–6 | Day of the week (0 = Sunday) |
+| minute | 0-59 | Minute of the hour |
+| hour | 0-23 | Hour of the day (24-hour format) |
+| day-of-month | 1-31 | Day of the month |
+| month | 1-12 | Month of the year |
+| day-of-week | 0-6 | Day of the week (0 = Sunday) |
-An asterisk (`*`) means "every" — so `* * * * *` means every minute of every hour of every day.
+An asterisk (`*`) means "every," so `* * * * *` means every minute of every hour of every day.
### Add the daily job
@@ -531,7 +531,7 @@ For example, if the script is in your home directory, the line might be:
0 7 * * * /Users/yourname/mainwp-health-check.sh
```
-**Note on PATH:** Cron runs in a minimal environment — it does not load your shell profile, so commands like `mainwpctl` or `jq` may not be found by their short names. If you encounter issues, add a `PATH` line at the top of your crontab:
+**Note on PATH:** Cron runs in a minimal environment. It does not load your shell profile, so commands like `mainwpctl` or `jq` may not be found by their short names. If you encounter issues, add a `PATH` line at the top of your crontab:
```
PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin
@@ -609,8 +609,8 @@ jq is not installed on your system. Install it:
You should see `ok` in the terminal and a message in Slack.
-- **Check the channel** — each webhook is tied to a specific Slack channel. Make sure you are looking in the right one.
-- **Ensure the Slack app is still active** — if someone disabled or deleted the app, the webhook will stop working. Check your Slack app settings at [https://api.slack.com/apps](https://api.slack.com/apps).
+- **Check the channel.** Each webhook is tied to a specific Slack channel. Make sure you are looking in the right one.
+- **Ensure the Slack app is still active.** If someone disabled or deleted the app, the webhook will stop working. Check your Slack app settings at [https://api.slack.com/apps](https://api.slack.com/apps).
### Cron job not running
@@ -618,9 +618,9 @@ jq is not installed on your system. Install it:
- On Linux: `grep CRON /var/log/syslog`
- On macOS: `log show --predicate 'process == "cron"' --last 1h`
-- **Make sure you used full paths** — both for the script and for any commands inside it (mainwpctl, jq, curl). Cron does not load your shell profile, so it may not find programs by their short names. Adding a `PATH` line to the top of your crontab (as shown in Step 8) usually resolves this.
+- **Make sure you used full paths** for both the script and any commands inside it (mainwpctl, jq, curl). Cron does not load your shell profile, so it may not find programs by their short names. Adding a `PATH` line to the top of your crontab (as shown in Step 8) usually resolves this.
-- **On macOS**, you may need to grant Terminal (or your terminal app) **Full Disk Access** in **System Settings → Privacy & Security → Full Disk Access**. Without this, cron may be silently blocked from running scripts.
+- **On macOS**, you may need to grant Terminal (or your terminal app) **Full Disk Access** in **System Settings > Privacy & Security > Full Disk Access**. Without this, cron may be silently blocked from running scripts.
### Permission denied when running the script
diff --git a/docs/workflows/input-from-file.md b/docs/workflows/input-from-file.md
index ac2fa1d..49065a2 100644
--- a/docs/workflows/input-from-file.md
+++ b/docs/workflows/input-from-file.md
@@ -1,8 +1,8 @@
# Input from File
-> Pass complex parameters to mainwpctl using JSON files, stdin pipes, or heredocs — instead of typing everything on the command line.
+> Pass complex parameters to mainwpctl using JSON files, stdin pipes, or heredocs instead of typing everything on the command line.
-MainWP Control is a command-line tool for managing your MainWP Dashboard and all the WordPress sites connected to it. Some operations need structured data as input — things like site IDs, lists of plugins, or nested configuration objects. This guide shows you three ways to pass that structured data without wrestling with long, error-prone command-line strings.
+MainWP Control is a command-line tool for managing your MainWP Dashboard and all the WordPress sites connected to it. Some operations need structured data as input: site IDs, lists of plugins, or nested configuration objects. This guide shows you three ways to pass that structured data without wrestling with long, error-prone command-line strings.
---
@@ -17,13 +17,13 @@ MainWP Control is a command-line tool for managing your MainWP Dashboard and all
## Why Use File Input?
-Some MainWP Control abilities need more than a simple flag. For example, updating specific plugins on a specific site requires a JSON object with a site ID and a list of plugin slugs. Typing this as an `--input` flag works for simple cases:
+Some MainWP Control abilities need more than a flag. For example, updating specific plugins on a specific site requires a JSON object with a site ID and a list of plugin slugs. Typing this as an `--input` flag works for simple cases:
```bash
mainwpctl abilities run get-site-v1 --input '{"site_id_or_domain": 5}' --json
```
-But when parameters get complex — nested objects, arrays, multiple fields — inline JSON becomes hard to read and easy to get wrong. A misplaced quote or missing comma can cause confusing errors.
+But when parameters get complex (nested objects, arrays, multiple fields), inline JSON becomes hard to read and easy to get wrong. A misplaced quote or missing comma can cause confusing errors.
File input solves this. You write your parameters in a file (or pipe them from another command), and MainWP Control reads them cleanly.
@@ -35,7 +35,7 @@ Before you start, make sure you have:
- **MainWP Dashboard 6 or later**, installed and running on a WordPress site you control
- **WordPress admin access** to that Dashboard site (you need to create an Application Password)
-- **Node.js 20 or later** on the machine where you will run MainWP Control (this is the computer you type commands on — your laptop, a server, a CI runner, etc.)
+- **Node.js 20 or later** on the machine where you will run MainWP Control (your laptop, a server, a CI runner, etc.)
If you are not sure whether Node.js is installed, open a terminal and run:
@@ -58,7 +58,7 @@ If you get "command not found" or a version below 20, install Node.js from [http
MainWP Control authenticates with your MainWP Dashboard using a WordPress Application Password. This is a special password that gives API access without sharing your main login credentials.
1. Log in to your WordPress Dashboard site as an administrator.
-2. Go to **Users → Your Profile** (click your username in the top-right, then "Edit Profile", or navigate to `/wp-admin/profile.php`).
+2. Go to **Users > Your Profile** (click your username in the top-right, then "Edit Profile", or navigate to `/wp-admin/profile.php`).
3. Scroll down to the **Application Passwords** section near the bottom of the page.
4. In the "New Application Password Name" field, type a name you will recognize later, for example: `mainwpctl`
5. Click **Add New Application Password**.
@@ -71,7 +71,7 @@ MainWP Control authenticates with your MainWP Dashboard using a WordPress Applic
7. **Copy this password immediately.** WordPress will not show it again. If you lose it, you will need to create a new one.
8. Store it somewhere secure (a password manager is ideal).
-The spaces in the password are optional — mainwpctl accepts the password with or without them.
+The spaces in the password are optional. mainwpctl accepts the password with or without them.
---
@@ -84,7 +84,7 @@ Open a terminal (on macOS: open the Terminal app; on Linux: open your terminal e
This installs MainWP Control so it is available everywhere on your system:
```bash
-npm install -g mainwpctl
+npm install -g @mainwp/control
```
### Option B: Run without installing
@@ -95,7 +95,7 @@ If you prefer not to install globally, you can run MainWP Control on demand usin
npx mainwpctl --version
```
-`npx` downloads and runs the package temporarily. Every example in this guide uses `mainwpctl` directly — if you chose Option B, replace `mainwpctl` with `npx mainwpctl` in every command.
+`npx` downloads and runs the package temporarily. Every example in this guide uses `mainwpctl` directly. If you chose Option B, replace `mainwpctl` with `npx mainwpctl` in every command.
### Verify the installation
@@ -123,9 +123,9 @@ mainwpctl login
MainWP Control will prompt you for three pieces of information:
-1. **Dashboard URL** — the full URL of your MainWP Dashboard site, for example `https://manage.example.com`
-2. **Username** — your WordPress admin username
-3. **Application Password** — the password you created in Step 1
+1. **Dashboard URL:** the full URL of your MainWP Dashboard site, for example `https://manage.example.com`
+2. **Username:** your WordPress admin username
+3. **Application Password:** the password you created in Step 1
Enter each value when prompted. MainWP Control will test the connection and store the credentials in a local profile. If the machine cannot use the OS keychain, keep `MAINWP_APP_PASSWORD` available in the environment for future runs.
@@ -177,7 +177,7 @@ JSON (JavaScript Object Notation) is a standard format for structured data, used
- **Number values** do not have quotes: `5` (not `"5"`)
- **Arrays** (ordered lists) use square brackets: `["akismet", "wordfence"]`
- **Objects** (groups of key-value pairs) use curly braces: `{"key": "value"}`
-- **No trailing commas** — the last item in an object or array must not have a comma after it
+- **No trailing commas:** the last item in an object or array must not have a comma after it
Here is a quick comparison of valid and invalid JSON:
@@ -190,7 +190,7 @@ Invalid: {'site_id': 5} ← single quotes are not allowed
### Create the file
-Open a text editor (any editor works — VS Code, nano, Notepad, TextEdit in plain text mode) and create a file called `params.json` with this content:
+Open a text editor (any editor works: VS Code, nano, Notepad, TextEdit in plain text mode) and create a file called `params.json` with this content:
```json
{
@@ -202,12 +202,12 @@ Save the file in a directory you can easily navigate to in your terminal.
Here is what each part means:
-- `{` and `}` — the outer curly braces define a JSON object (a collection of key-value pairs)
-- `"site_id_or_domain"` — the key, which tells mainwpctl which parameter you are setting
-- `:` — separates the key from the value
-- `5` — the value (a number representing the site ID in your MainWP Dashboard)
+- `{` and `}`: the outer curly braces define a JSON object (a collection of key-value pairs)
+- `"site_id_or_domain"`: the key, which tells mainwpctl which parameter you are setting
+- `:` separates the key from the value
+- `5`: the value (a number representing the site ID in your MainWP Dashboard)
-This is the simplest possible parameter file — it passes a single site ID to an ability.
+This is the simplest possible parameter file. It passes a single site ID to an ability.
**How do you find your site ID?** Run `mainwpctl abilities run list-sites-v1 --json` to see all your connected sites. Each site in the output will have an `id` field. Replace `5` with your actual site ID.
@@ -222,9 +222,9 @@ For updating specific plugins on a site, you might need:
}
```
-This adds a second parameter called `plugins`. The value is an array (a list) containing two plugin slugs. A plugin slug is the short name used in the plugin's directory — you can find it by running `mainwpctl abilities run get-site-plugins-v1 --input '{"site_id_or_domain": 5}' --json` and looking at the `slug` field for each plugin.
+This adds a second parameter called `plugins`. The value is an array (a list) containing two plugin slugs. A plugin slug is the short name used in the plugin's directory. You can find it by running `mainwpctl abilities run get-site-plugins-v1 --input '{"site_id_or_domain": 5}' --json` and looking at the `slug` field for each plugin.
-Notice the comma after `5` on the first line — commas separate items within an object. There is no comma after the last item (`"plugins"` line).
+Notice the comma after `5` on the first line. Commas separate items within an object. There is no comma after the last item (`"plugins"` line).
---
@@ -238,10 +238,10 @@ mainwpctl abilities run get-site-v1 --input-file params.json --json
Here is what each part of this command does:
-- `mainwpctl abilities run` — tells MainWP Control to execute an ability
-- `get-site-v1` — the name of the ability (fetches details about a specific site)
-- `--input-file params.json` — read the parameters from your `params.json` file instead of the command line
-- `--json` — output the result as structured JSON (easier to read and use in scripts)
+- `mainwpctl abilities run`: tells MainWP Control to execute an ability
+- `get-site-v1`: the name of the ability (fetches details about a specific site)
+- `--input-file params.json`: read the parameters from your `params.json` file instead of the command line
+- `--json`: output the result as structured JSON (easier to read and use in scripts)
The file path (`params.json`) is relative to the directory where you run the command. If you are in `/home/user/projects` and the file is at `/home/user/projects/params.json`, then `params.json` works. You can also use an absolute path:
@@ -274,14 +274,14 @@ Expected output (your values will differ):
### Destructive operations
-Some abilities make changes — updating plugins, deleting themes, modifying site settings. These are called destructive operations. For safety, MainWP Control requires you to explicitly confirm them:
+Some abilities make changes: updating plugins, deleting themes, modifying site settings. These are called destructive operations. For safety, MainWP Control requires you to explicitly confirm them:
```bash
mainwpctl abilities run update-site-plugins-v1 --input-file params.json --confirm --force
```
-- `--confirm` — tells MainWP Control you have reviewed the action and want to proceed
-- `--force` — skips the interactive confirmation prompt (useful in scripts and CI/CD pipelines)
+- `--confirm`: tells MainWP Control you have reviewed the action and want to proceed
+- `--force`: skips the interactive confirmation prompt (useful in scripts and CI/CD pipelines)
Without `--confirm --force`, MainWP Control will show you a preview of what will happen and ask you to approve before executing.
@@ -303,9 +303,9 @@ echo '{"site_id_or_domain": 5}' | mainwpctl abilities run get-site-v1 --input -
Here is what happens:
-1. `echo '{"site_id_or_domain": 5}'` — the `echo` command outputs the JSON string to the terminal
-2. `|` — the pipe takes that output and sends it as input to the next command
-3. `mainwpctl abilities run get-site-v1 --input - --json` — mainwpctl runs the ability, and `--input -` tells it to read parameters from stdin (the `-` character means "read from the pipe instead of from a flag value")
+1. `echo '{"site_id_or_domain": 5}'`: the `echo` command outputs the JSON string to the terminal
+2. `|`: the pipe takes that output and sends it as input to the next command
+3. `mainwpctl abilities run get-site-v1 --input - --json`: mainwpctl runs the ability, and `--input -` tells it to read parameters from stdin (the `-` character means "read from the pipe instead of from a flag value")
Expected output:
@@ -346,7 +346,7 @@ printf '{"site_id_or_domain": %d}' 5 | mainwpctl abilities run get-site-v1 --inp
`printf` is like `echo` but gives you more control over formatting. The `%d` is a placeholder that gets replaced with the number `5`. This is useful in scripts where the site ID comes from a variable or another command's output.
-Expected output is the same as before — the site details as JSON.
+Expected output is the same as before: the site details as JSON.
---
@@ -356,7 +356,7 @@ Expected output is the same as before — the site details as JSON.
A heredoc (short for "here document") is a way to write multi-line text directly inside a shell command, without creating a separate file. It is especially useful for JSON because JSON is often easier to read when spread across multiple lines.
-The syntax `<<'EOF'` means: "everything from here until you see `EOF` on its own line is the input." `EOF` is just a marker word — you could use any word, but `EOF` (short for "end of file") is the convention.
+The syntax `<<'EOF'` means: "everything from here until you see `EOF` on its own line is the input." `EOF` is a marker word. You could use any word, but `EOF` (short for "end of file") is the convention.
### Basic heredoc
@@ -370,10 +370,10 @@ EOF
Here is what each part does:
-- `mainwpctl abilities run get-site-v1 --input - --json` — runs the ability, reading parameters from stdin
-- `<<'EOF'` — starts the heredoc. The single quotes around `EOF` are important (explained below)
+- `mainwpctl abilities run get-site-v1 --input - --json`: runs the ability, reading parameters from stdin
+- `<<'EOF'`: starts the heredoc. The single quotes around `EOF` are important (explained below)
- The lines between `<<'EOF'` and `EOF` are the JSON content sent as stdin
-- `EOF` on its own line (no spaces before it, nothing after it) — ends the heredoc
+- `EOF` on its own line (no spaces before it, nothing after it): ends the heredoc
Expected output:
@@ -411,19 +411,19 @@ This sends a multi-line JSON object with a site ID and a list of plugins to upda
This is an important detail. There are two ways to start a heredoc:
-- `<<'EOF'` (with single quotes) — the text is sent **literally**. Characters like `$` are treated as plain text.
-- `< Send MainWP site metrics to your monitoring system (Datadog, StatsD, or similar) so you can track site health over time and set up alerts.
-Managing a network of WordPress sites generates useful numbers — how many sites you have, how many need updates, how many are disconnected. These numbers are valuable for spotting trends and catching problems early, but only if they are tracked over time in a monitoring system.
+Managing a network of WordPress sites generates useful numbers: how many sites you have, how many need updates, how many are disconnected. These numbers are valuable for spotting trends and catching problems early, but only if they are tracked over time in a monitoring system.
This guide walks you through extracting metrics from your MainWP Dashboard using MainWP Control, sending them to a StatsD-compatible monitoring service, and scheduling the whole thing to run automatically. You do not need any prior experience with scripting, monitoring protocols, or the command line. Every concept is explained before it is used.
@@ -24,8 +24,8 @@ Before you begin, make sure you have the following:
- **MainWP Dashboard 6 or later**, installed and running on a WordPress site. This is the central hub that manages your connected sites.
- **WordPress admin access** to your Dashboard site. You will need to create an Application Password for CLI authentication.
-- **Node.js 20 or later** installed on the machine that will run MainWP Control. This is the machine where the monitoring script will execute — it does not need to be the same server as your Dashboard.
-- **A StatsD-compatible monitoring service** (Datadog Agent, Telegraf, Graphite, etc.) listening on UDP port 8125. If you use a different monitoring tool, the concepts are the same — you will just need to adapt the "send" step for your tool's protocol.
+- **Node.js 20 or later** installed on the machine that will run MainWP Control. This is the machine where the monitoring script will execute. It does not need to be the same server as your Dashboard.
+- **A StatsD-compatible monitoring service** (Datadog Agent, Telegraf, Graphite, etc.) listening on UDP port 8125. If you use a different monitoring tool, the concepts are the same. You will need to adapt the "send" step for your tool's protocol.
---
@@ -34,9 +34,9 @@ Before you begin, make sure you have the following:
Application Passwords are a WordPress feature that lets external tools (like MainWP Control) authenticate with your site without using your main login credentials. Each Application Password is a separate credential you can revoke independently.
1. Log in to your WordPress Dashboard site as an administrator.
-2. Navigate to **Users → Your Profile** (or click your name in the top-right corner and select "Edit Profile").
+2. Navigate to **Users > Your Profile** (or click your name in the top-right corner and select "Edit Profile").
3. Scroll down to the **Application Passwords** section near the bottom of the page.
-4. In the **"New Application Password Name"** field, type a name to identify this credential — for example, `mainwpctl`.
+4. In the **"New Application Password Name"** field, type a name to identify this credential, for example `mainwpctl`.
5. Click **"Add New Application Password"**.
6. WordPress will display a generated password. It looks something like this:
@@ -45,7 +45,7 @@ Application Passwords are a WordPress feature that lets external tools (like Mai
```
7. **Copy this password immediately.** WordPress will not show it again. If you lose it, you will need to create a new one.
-8. Store it somewhere secure — a password manager, a CI/CD secret store, or a secure note. You will need it in Step 3.
+8. Store it somewhere secure: a password manager, a CI/CD secret store, or a secure note. You will need it in Step 3.
---
@@ -60,7 +60,7 @@ MainWP Control is a command-line tool distributed as an npm package. npm is the
A global install makes `mainwpctl` available as a command anywhere on your system:
```bash
-npm install -g mainwpctl
+npm install -g @mainwp/control
```
### Option B: Run without installing (npx)
@@ -101,9 +101,9 @@ mainwpctl login
You will be prompted for three pieces of information:
-1. **Dashboard URL** — The full URL of your MainWP Dashboard site (e.g., `https://manage.example.com`).
-2. **Username** — Your WordPress admin username on the Dashboard site.
-3. **Application Password** — The password you created in Step 1. Paste it in when prompted (the spaces in the password are fine — include them or omit them, both work).
+1. **Dashboard URL:** The full URL of your MainWP Dashboard site (e.g., `https://manage.example.com`).
+2. **Username:** Your WordPress admin username on the Dashboard site.
+3. **Application Password:** The password you created in Step 1. Paste it in when prompted. The spaces in the password are fine; include them or omit them, both work.
After entering these, MainWP Control stores your credentials in your system's keychain when one is available (macOS Keychain, Linux secret service, or Windows Credential Manager). If the machine cannot use a keychain, keep `MAINWP_APP_PASSWORD` available in the environment for future runs.
@@ -210,7 +210,7 @@ Expected output (abbreviated):
}
```
-The exact sites will match your Dashboard. Now extract just the count using jq:
+The exact sites will match your Dashboard. Now extract the count using jq:
```bash
mainwpctl abilities run list-sites-v1 --json | jq '.data.data.items | length'
@@ -218,8 +218,8 @@ mainwpctl abilities run list-sites-v1 --json | jq '.data.data.items | length'
Here is what the jq expression means:
-- `.data.data.items` — Navigate into the JSON: start at the root, go into the outer `data` object, then the inner `data` object, then `items` (which is an array of sites).
-- `| length` — Count how many items are in that array.
+- `.data.data.items`: Navigate into the JSON. Start at the root, go into the outer `data` object, then the inner `data` object, then `items` (which is an array of sites).
+- `| length`: Count how many items are in that array.
The `|` between commands is a **pipe**. It takes the output of the command on the left and feeds it as input to the command on the right.
@@ -237,7 +237,7 @@ The number will match how many sites are connected to your Dashboard.
mainwpctl abilities run list-updates-v1 --json | jq '.data.data.total // 0'
```
-The jq expression `.data.data.total // 0` means: "get the `total` field from the inner `data` object, or use `0` if it does not exist." The `//` operator is jq's alternative operator — it provides a fallback value when a field is missing or null.
+The jq expression `.data.data.total // 0` means: "get the `total` field from the inner `data` object, or use `0` if it does not exist." The `//` operator is jq's alternative operator, providing a fallback value when a field is missing or null.
Expected output:
@@ -255,9 +255,9 @@ mainwpctl abilities run list-sites-v1 --json | jq '[.data.data.items[] | select(
This jq expression is more involved, so here is what each part does:
-- `.data.data.items[]` — Iterate over every item in the `items` array.
-- `select(.status != "connected")` — Keep only the sites whose `status` field is not `"connected"`.
-- `[...] | length` — Wrap the filtered results in an array and count how many items are in it.
+- `.data.data.items[]`: Iterate over every item in the `items` array.
+- `select(.status != "connected")`: Keep only the sites whose `status` field is not `"connected"`.
+- `[...] | length`: Wrap the filtered results in an array and count how many items are in it.
Expected output if all sites are connected:
@@ -279,7 +279,7 @@ Now that you know how to extract metrics, the next step is sending them to your
### What is StatsD?
-StatsD is a protocol for sending metrics to monitoring systems. It is text-based and simple — you send a string like `metric.name:value|type` over UDP to port 8125. Datadog, Graphite, Telegraf, and many other monitoring tools understand this protocol. If your tool accepts StatsD metrics, these examples will work as-is.
+StatsD is a protocol for sending metrics to monitoring systems. It is text-based and straightforward: you send a string like `metric.name:value|type` over UDP to port 8125. Datadog, Graphite, Telegraf, and many other monitoring tools understand this protocol. If your tool accepts StatsD metrics, these examples will work as-is.
### What is netcat (nc)?
@@ -287,7 +287,7 @@ StatsD is a protocol for sending metrics to monitoring systems. It is text-based
### Building the one-liner piece by piece
-Start with just getting the count and printing it:
+Start with getting the count and printing it:
```bash
SITE_COUNT=$(mainwpctl abilities run list-sites-v1 --json | jq '.data.data.items | length')
@@ -317,9 +317,9 @@ mainwp.sites.total:12|g
Here is what this string means:
-- `mainwp.sites.total` — The metric name. You choose this. Use dots to create a hierarchy (like a folder structure) in your monitoring dashboard.
-- `:${SITE_COUNT}` — The value. The `${}` syntax inserts the variable's value into the string.
-- `|g` — The metric type. `g` stands for **gauge**, which is a value that goes up and down (like a count). Other types include `c` for counter (increments only) and `ms` for timing.
+- `mainwp.sites.total`: The metric name. You choose this. Use dots to create a hierarchy (like a folder structure) in your monitoring dashboard.
+- `:${SITE_COUNT}`: The value. The `${}` syntax inserts the variable's value into the string.
+- `|g`: The metric type. `g` stands for **gauge**, a value that goes up and down (like a count). Other types include `c` for counter (increments only) and `ms` for timing.
Finally, send it to StatsD:
@@ -332,12 +332,12 @@ mainwpctl abilities run list-sites-v1 --json | \
Each line in this pipeline does one thing:
-1. `mainwpctl abilities run list-sites-v1 --json` — Fetches the site data as JSON from your MainWP Dashboard.
-2. `jq '.data.data.items | length'` — Extracts the site count from the JSON.
-3. `xargs -I {} echo "mainwp.sites.total:{}|g"` — Formats the count as a StatsD metric string. `xargs` takes the input (the count) and passes it to the `echo` command. `-I {}` means "replace `{}` with the input value."
-4. `nc -u -w1 localhost 8125` — Sends the formatted string via UDP (`-u`) to localhost port 8125 with a 1-second timeout (`-w1`).
+1. `mainwpctl abilities run list-sites-v1 --json`: Fetches the site data as JSON from your MainWP Dashboard.
+2. `jq '.data.data.items | length'`: Extracts the site count from the JSON.
+3. `xargs -I {} echo "mainwp.sites.total:{}|g"`: Formats the count as a StatsD metric string. `xargs` takes the input (the count) and passes it to the `echo` command. `-I {}` means "replace `{}` with the input value."
+4. `nc -u -w1 localhost 8125`: Sends the formatted string via UDP (`-u`) to localhost port 8125 with a 1-second timeout (`-w1`).
-The `\` at the end of each line tells the shell that the command continues on the next line. This is purely for readability — you could write the entire command on one line.
+The `\` at the end of each line tells the shell that the command continues on the next line. This is for readability only; you could write the entire command on one line.
There is no output from this command if it succeeds. The metric is sent silently to StatsD.
@@ -364,7 +364,7 @@ The only differences from the previous step:
## Step 7: Add Error Detection
-What happens when MainWP Control fails — for example, if your Dashboard is unreachable or your credentials have expired? The jq parsing will fail or produce garbage, and you will send a wrong metric value to your monitoring system without knowing.
+What happens when MainWP Control fails, for example if your Dashboard is unreachable or your credentials have expired? The jq parsing will fail or produce garbage, and you will send a wrong metric value to your monitoring system without knowing.
Error handling fixes this. Here is a version that detects failure and sends a different metric to alert you:
@@ -382,12 +382,12 @@ fi
Here is what is new:
-- `2>/dev/null` — Redirects error messages (called **stderr**, or "standard error") to `/dev/null`, which discards them. This keeps the captured output clean — only JSON goes into `RESULT`.
-- `EXIT=$?` — The special variable `$?` contains the **exit code** of the most recently run command. An exit code of `0` means success. Any other number means something went wrong.
-- `if [ $EXIT -ne 0 ]; then` — This is a **conditional**. `-ne` means "not equal to." So this reads: "if the exit code is not equal to zero, then..."
-- `echo "mainwp.check.failed:1|c"` — Notice the metric type is `|c` instead of `|g`. The `c` stands for **counter**. Unlike a gauge, a counter increments: each time this line runs, the count goes up by 1. This lets you set up alerts in your monitoring dashboard like "alert me if `mainwp.check.failed` increments more than 3 times in the last hour."
-- `else` — Runs the normal metric-sending code when the command succeeds.
-- `fi` — Marks the end of the `if` block.
+- `2>/dev/null`: Redirects error messages (called **stderr**, or "standard error") to `/dev/null`, which discards them. This keeps the captured output clean, so only JSON goes into `RESULT`.
+- `EXIT=$?`: The special variable `$?` contains the **exit code** of the most recently run command. An exit code of `0` means success. Any other number means something went wrong.
+- `if [ $EXIT -ne 0 ]; then`: This is a **conditional**. `-ne` means "not equal to." So this reads: "if the exit code is not equal to zero, then..."
+- `echo "mainwp.check.failed:1|c"`: Notice the metric type is `|c` instead of `|g`. The `c` stands for **counter**. Unlike a gauge, a counter increments: each time this line runs, the count goes up by 1. This lets you set up alerts in your monitoring dashboard like "alert me if `mainwp.check.failed` increments more than 3 times in the last hour."
+- `else`: Runs the normal metric-sending code when the command succeeds.
+- `fi`: Marks the end of the `if` block.
---
@@ -407,7 +407,7 @@ Start with this skeleton:
```bash
#!/bin/bash
-# mainwp-metrics.sh — Send MainWP metrics to StatsD/Datadog
+# mainwp-metrics.sh - Send MainWP metrics to StatsD/Datadog
STATSD_HOST="localhost"
STATSD_PORT="8125"
@@ -419,9 +419,9 @@ send_metric() {
Here is what each part does:
-- `#!/bin/bash` — The **shebang** line. It tells your operating system to run this script using Bash.
-- `STATSD_HOST` and `STATSD_PORT` — Configuration variables. If your StatsD agent runs on a different host or port, change these values here instead of hunting through the script.
-- `send_metric()` — A **function**. It is a reusable shortcut so you do not repeat the `nc` command every time you send a metric. When you call `send_metric "mainwp.sites.total:12|g"`, the `$1` inside the function is replaced with that string.
+- `#!/bin/bash`: The **shebang** line. It tells your operating system to run this script using Bash.
+- `STATSD_HOST` and `STATSD_PORT`: Configuration variables. If your StatsD agent runs on a different host or port, change these values here instead of hunting through the script.
+- `send_metric()`: A **function**. It is a reusable shortcut so you do not repeat the `nc` command every time you send a metric. When you call `send_metric "mainwp.sites.total:12|g"`, the `$1` inside the function is replaced with that string.
### 8b: Add site metrics
@@ -440,9 +440,9 @@ else
fi
```
-Notice that we call `mainwpctl abilities run list-sites-v1` only once and extract two metrics (total count and disconnected count) from the same response. This is efficient — each API call takes a few seconds, so reusing the result saves time.
+Notice that we call `mainwpctl abilities run list-sites-v1` only once and extract two metrics (total count and disconnected count) from the same response. This is efficient: each API call takes a few seconds, so reusing the result saves time.
-The `$? -eq 0` check means "the exit code equals zero" — i.e., the command succeeded. `-eq` means "equal to."
+The `$? -eq 0` check means "the exit code equals zero," i.e., the command succeeded. `-eq` means "equal to."
### 8c: Add update metrics
@@ -465,7 +465,7 @@ Here is the complete script with all sections combined:
```bash
#!/bin/bash
-# mainwp-metrics.sh — Send MainWP metrics to StatsD/Datadog
+# mainwp-metrics.sh - Send MainWP metrics to StatsD/Datadog
STATSD_HOST="localhost"
STATSD_PORT="8125"
@@ -564,7 +564,7 @@ Run through this checklist to confirm everything is connected:
./mainwp-metrics.sh
```
- The script produces no terminal output on success — it sends metrics silently to StatsD.
+ The script produces no terminal output on success. It sends metrics silently to StatsD.
2. **Check your monitoring dashboard** for the new metrics:
- `mainwp.sites.total`
@@ -687,7 +687,7 @@ Look at the actual structure and adjust the jq expressions accordingly.
The `/opt/homebrew/bin` entry is for macOS with Homebrew on Apple Silicon.
-- **On macOS**, you may need to grant Terminal (or your terminal app) **Full Disk Access** in **System Settings → Privacy & Security → Full Disk Access**. Without this, cron may be silently blocked from running scripts.
+- **On macOS**, you may need to grant Terminal (or your terminal app) **Full Disk Access** in **System Settings > Privacy & Security > Full Disk Access**. Without this, cron may be silently blocked from running scripts.
### Authentication errors in cron
diff --git a/docs/workflows/monthly-batch-updates.md b/docs/workflows/monthly-batch-updates.md
index 08b417d..3bcaad0 100644
--- a/docs/workflows/monthly-batch-updates.md
+++ b/docs/workflows/monthly-batch-updates.md
@@ -1,11 +1,11 @@
# Monthly Batch Updates
-> Safely preview and apply WordPress core, plugin, and theme updates across all your MainWP sites — either from a script or automatically via GitHub Actions.
+> Safely preview and apply WordPress core, plugin, and theme updates across all your MainWP sites, either from a script or through GitHub Actions.
-This guide walks you through two ways to automate monthly updates for every WordPress site connected to your MainWP Dashboard:
+This guide covers two ways to automate monthly updates for every WordPress site connected to your MainWP Dashboard:
-- **Option A** — A bash script you run manually or schedule with cron
-- **Option B** — A GitHub Actions workflow that runs automatically on the 1st of each month
+- **Option A:** A bash script you run manually or schedule with cron
+- **Option B:** A GitHub Actions workflow that runs automatically on the 1st of each month
Both options follow the same pattern: preview what will change, apply the updates, and verify the result. You only need to pick one, but the guide builds the concepts incrementally so Option B builds on what you learn in Option A.
@@ -32,12 +32,12 @@ Before starting, make sure you have:
## Step 1: Create an Application Password
-MainWP Control authenticates with your MainWP Dashboard using a WordPress Application Password. This is a built-in WordPress feature (no extra plugins needed) that generates a separate password specifically for API access, so you never expose your main login credentials.
+MainWP Control authenticates with your MainWP Dashboard using a WordPress Application Password. This is a built-in WordPress feature (no extra plugins needed) that generates a separate password for API access, so you never expose your main login credentials.
To create one:
1. Log in to the WordPress site where MainWP Dashboard is installed.
-2. Go to **Users** in the left sidebar, then click on your own user profile (or go to **Users → Your Profile**).
+2. Go to **Users** in the left sidebar, then click on your own user profile (or go to **Users > Your Profile**).
3. Scroll down to the **Application Passwords** section near the bottom of the page.
4. In the **New Application Password Name** field, type a descriptive name like `mainwpctl`.
5. Click **Add New Application Password**.
@@ -48,7 +48,7 @@ To create one:
```
7. **Copy this password immediately.** WordPress will not show it again. If you lose it, you will need to revoke it and create a new one.
-8. Store the password securely — in a password manager, as a CI/CD secret, or in an encrypted note. Do not paste it into files that are committed to version control.
+8. Store the password securely: in a password manager, as a CI/CD secret, or in an encrypted note. Do not paste it into files that are committed to version control.
---
@@ -57,7 +57,7 @@ To create one:
Install MainWP Control globally using npm (the Node.js package manager that comes with Node.js):
```bash
-npm install -g mainwpctl
+npm install -g @mainwp/control
```
This makes the `mainwpctl` command available anywhere on your system.
@@ -98,9 +98,9 @@ mainwpctl login
MainWP Control will prompt you for three pieces of information:
-1. **Dashboard URL** — The full URL of your MainWP Dashboard site (e.g., `https://dashboard.example.com`). Include the `https://` prefix.
-2. **Username** — Your WordPress admin username on that site.
-3. **Application Password** — The password you created in Step 1.
+1. **Dashboard URL:** The full URL of your MainWP Dashboard site (e.g., `https://dashboard.example.com`). Include the `https://` prefix.
+2. **Username:** Your WordPress admin username on that site.
+3. **Application Password:** The password you created in Step 1.
After entering your credentials, MainWP Control stores them in a local profile so you do not need to re-enter them each time. If the machine cannot use the OS keychain, keep `MAINWP_APP_PASSWORD` available in the environment for future runs.
@@ -146,15 +146,15 @@ Before writing any scripts, it is important to understand how MainWP Control pre
MainWP Control enforces this through four flags:
-- **`--dry-run`** — Asks MainWP to show you what *would* happen, without making any changes. Think of it as a preview. Your sites are not touched. You can run `--dry-run` as many times as you want with zero risk.
+- **`--dry-run`:** Asks MainWP to show you what *would* happen, without making any changes. Think of it as a preview. Your sites are not touched. You can run `--dry-run` as many times as you want with zero risk.
-- **`--confirm`** — Tells MainWP to go ahead and execute the operation for real. This is required for any operation that changes something (applying updates, deleting plugins, etc.). Without `--confirm`, the command will only show you what it would do.
+- **`--confirm`:** Tells MainWP to go ahead and execute the operation for real. This is required for any operation that changes something (applying updates, deleting plugins, etc.). Without `--confirm`, the command will only show you what it would do.
-- **`--force`** — Skips the interactive "Are you sure?" prompt that MainWP Control normally shows before destructive operations. This is necessary in scripts and CI/CD pipelines where there is no human to type "yes." It does not bypass the preview/confirm model — it only skips the interactive prompt.
+- **`--force`:** Skips the interactive "Are you sure?" prompt that MainWP Control normally shows before destructive operations. This is necessary in scripts and CI/CD pipelines where there is no human to type "yes." It does not bypass the preview/confirm model. It only skips the interactive prompt.
-- **`--wait`** — Keeps the command running until the batch operation finishes on the server. Without this flag, the command returns immediately with a job ID, and the updates continue in the background. With `--wait`, the command blocks until all updates are complete and then returns the final result.
+- **`--wait`:** Keeps the command running until the batch operation finishes on the server. Without this flag, the command returns immediately with a job ID, and the updates continue in the background. With `--wait`, the command blocks until all updates are complete and then returns the final result.
-**Important:** `--dry-run` and `--confirm` are mutually exclusive. You cannot preview and execute at the same time. If you pass both, MainWP Control will return an error. This is intentional — it forces you to make the preview and execution separate, deliberate steps.
+**Important:** `--dry-run` and `--confirm` are mutually exclusive. You cannot preview and execute at the same time. If you pass both, MainWP Control will return an error. This is intentional. It forces you to make the preview and execution separate, deliberate steps.
The typical flow in any script is:
@@ -181,9 +181,9 @@ mainwpctl abilities run list-updates-v1 --json
Breaking this command down:
-- `abilities run` — Tells MainWP Control to execute a MainWP ability (an action the API can perform).
-- `list-updates-v1` — The specific ability to run. This one lists all pending updates across your network.
-- `--json` — Outputs the result as JSON instead of a human-readable table. This makes the output easy to parse in scripts.
+- `abilities run`: Tells MainWP Control to execute a MainWP ability (an action the API can perform).
+- `list-updates-v1`: The specific ability to run. This one lists all pending updates across your network.
+- `--json`: Outputs the result as JSON instead of a human-readable table. This makes the output easy to parse in scripts.
Expected output (abbreviated):
@@ -223,14 +223,14 @@ The `data.data.total` field tells you how many updates are pending (the inner `d
### Step 5: Preview Pending Updates
-Now check what updates are available. `list-updates-v1` is a read-only ability — it shows you what is pending without changing anything:
+Now check what updates are available. `list-updates-v1` is a read-only ability. It shows you what is pending without changing anything:
```bash
PREVIEW=$(mainwpctl abilities run list-updates-v1 --json)
echo "$PREVIEW"
```
-The first line runs the command and stores its output in a variable called `PREVIEW`. A variable in bash is a named container that holds a value — here it holds the JSON output so you can use it again without re-running the command. The second line prints the stored output to your terminal.
+The first line runs the command and stores its output in a variable called `PREVIEW`. A variable in bash is a named container that holds a value. Here it holds the JSON output so you can use it again without re-running the command. The second line prints the stored output to your terminal.
Expected output (abbreviated):
@@ -266,9 +266,9 @@ Expected output (abbreviated):
}
```
-This tells you exactly what updates are pending — which sites, which plugins/themes/core versions, and what version they will move to. Nothing has been changed.
+This tells you exactly what updates are pending: which sites, which plugins/themes/core versions, and what version they will move to. Nothing has been changed.
-Next, extract just the update count using `jq`. `jq` is a command-line tool for reading and manipulating JSON data. If you do not have it installed, you can install it with `brew install jq` on macOS or `sudo apt-get install jq` on Ubuntu/Debian:
+Next, extract the update count using `jq`. `jq` is a command-line tool for reading and manipulating JSON data. If you do not have it installed, you can install it with `brew install jq` on macOS or `sudo apt-get install jq` on Ubuntu/Debian:
```bash
PREVIEW=$(mainwpctl abilities run list-updates-v1 --json)
@@ -276,7 +276,7 @@ UPDATE_COUNT=$(echo "$PREVIEW" | jq '.data.data.total // 0')
echo "$UPDATE_COUNT updates pending"
```
-The `jq '.data.data.total // 0'` part reads the `total` field from inside the inner `data` object in the JSON. The `// 0` means "if that field does not exist or is null, use 0 instead" — this prevents the script from breaking if the response format is unexpected.
+The `jq '.data.data.total // 0'` part reads the `total` field from inside the inner `data` object in the JSON. The `// 0` means "if that field does not exist or is null, use 0 instead." This prevents the script from breaking if the response format is unexpected.
Expected output:
@@ -294,10 +294,10 @@ mainwpctl abilities run run-updates-v1 --confirm --force --wait --json
Here is what each flag does in this context:
-- `--confirm` — "Yes, apply these updates for real." This is the flag that transitions from preview to execution.
-- `--force` — Skip the interactive "Are you sure?" prompt. In a script, there is no human to respond to the prompt, so this flag is required.
-- `--wait` — Block and wait until all updates have finished applying across all sites. Without this, the command would return immediately with a job ID while updates continue in the background. With `--wait`, the command stays running and gives you the final result when everything is done.
-- `--json` — Output the result as JSON for easy parsing.
+- `--confirm`: "Yes, apply these updates for real." This is the flag that transitions from preview to execution.
+- `--force`: Skip the interactive "Are you sure?" prompt. In a script, there is no human to respond to the prompt, so this flag is required.
+- `--wait`: Block and wait until all updates have finished applying across all sites. Without this, the command would return immediately with a job ID while updates continue in the background. With `--wait`, the command stays running and gives you the final result when everything is done.
+- `--json`: Output the result as JSON for easy parsing.
Expected output (abbreviated):
@@ -345,7 +345,7 @@ Expected output:
0 updates remaining after run
```
-If the number is not zero, it could mean some updates failed, or new updates appeared while the previous batch was running. This is not necessarily a problem — the Troubleshooting section at the end covers this scenario.
+If the number is not zero, some updates may have failed, or new updates appeared while the previous batch was running. This is not necessarily a problem. The Troubleshooting section at the end covers this scenario.
### Complete Script
@@ -383,10 +383,10 @@ echo "$REMAINING updates remaining after run"
The script follows the same four-step flow you built piece by piece:
-1. **Preview** — List pending updates to see what would change
-2. **Check count** — Extract the number of pending updates; exit early if there are none
-3. **Apply** — Run with `--confirm --force --wait` to apply all updates and wait for completion
-4. **Verify** — List updates again to confirm everything was applied
+1. **Preview:** List pending updates to see what would change
+2. **Check count:** Extract the number of pending updates; exit early if there are none
+3. **Apply:** Run with `--confirm --force --wait` to apply all updates and wait for completion
+4. **Verify:** List updates again to confirm everything was applied
Make the script executable (this tells your operating system the file can be run as a program):
@@ -419,7 +419,7 @@ Nothing to update
### Scheduling with Cron
-To run this script automatically on the 1st of every month, you can use cron — a built-in scheduling tool on macOS and Linux. Open your cron configuration:
+To run this script automatically on the 1st of every month, you can use cron, a built-in scheduling tool on macOS and Linux. Open your cron configuration:
```bash
crontab -e
@@ -474,7 +474,7 @@ GitHub secrets are encrypted values stored in your repository settings. They are
| `DASHBOARD_USER` | Your WordPress admin username | `admin` |
| `MAINWP_APP_PASSWORD` | The Application Password from Step 1 | `AbCD 1234 efGH 5678 ijKL 9012` |
-For each one, type the name in the **Name** field, paste the value in the **Secret** field, and click **Add secret**. Once saved, the value is encrypted and cannot be viewed again — only updated or deleted.
+For each one, type the name in the **Name** field, paste the value in the **Secret** field, and click **Add secret**. Once saved, the value is encrypted and cannot be viewed again, only updated or deleted.
### Step 9: Create the Workflow File
@@ -493,9 +493,9 @@ on:
workflow_dispatch:
```
-- `name` — A human-readable name that appears in the GitHub Actions tab.
-- `schedule` — Runs the workflow on a cron schedule. The cron syntax `'0 6 1 * *'` means "6:00 AM UTC on the 1st of each month" — the same schedule used in the bash cron example.
-- `workflow_dispatch` — Adds a "Run workflow" button in the GitHub Actions tab so you can trigger it manually at any time. This is invaluable for testing.
+- `name`: A human-readable name that appears in the GitHub Actions tab.
+- `schedule`: Runs the workflow on a cron schedule. The cron syntax `'0 6 1 * *'` means "6:00 AM UTC on the 1st of each month," the same schedule used in the bash cron example.
+- `workflow_dispatch`: Adds a "Run workflow" button in the GitHub Actions tab so you can trigger it manually at any time. Essential for testing.
#### Job Setup
@@ -511,13 +511,13 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
- - run: npm install -g mainwpctl
+ - run: npm install -g @mainwp/control
```
-- `jobs` — Defines the work to do. Each job runs on a fresh virtual machine.
-- `runs-on: ubuntu-latest` — Uses the latest Ubuntu Linux runner provided by GitHub.
-- `actions/setup-node@v4` — A pre-built action that installs Node.js. We specify version 20 to match MainWP Control's requirements.
-- `npm install -g mainwpctl` — Installs MainWP Control globally on the runner, just like you did on your own machine in Step 2.
+- `jobs`: Defines the work to do. Each job runs on a fresh virtual machine.
+- `runs-on: ubuntu-latest`: Uses the latest Ubuntu Linux runner provided by GitHub.
+- `actions/setup-node@v4`: A pre-built action that installs Node.js. We specify version 20 to match MainWP Control's requirements.
+- `npm install -g @mainwp/control`: Installs MainWP Control globally on the runner, the same way you did on your own machine in Step 2.
#### Authentication
@@ -529,9 +529,9 @@ jobs:
--username $DASHBOARD_USER
```
-- `env` — Sets job-level environment variables so every `mainwpctl` step can authenticate. GitHub runners often do not persist credentials in an OS keychain between steps, so `MAINWP_APP_PASSWORD` must stay available for the whole job.
-- `${{ secrets.DASHBOARD_URL }}` — GitHub replaces this with the encrypted secret value at runtime. The actual value never appears in logs.
-- The `>` after `run:` is YAML syntax for a folded string — it joins the following indented lines into a single command, which makes long commands easier to read.
+- `env`: Sets job-level environment variables so every `mainwpctl` step can authenticate. GitHub runners often do not persist credentials in an OS keychain between steps, so `MAINWP_APP_PASSWORD` must stay available for the whole job.
+- `${{ secrets.DASHBOARD_URL }}`: GitHub replaces this with the encrypted secret value at runtime. The actual value never appears in logs.
+- The `>` after `run:` is YAML syntax for a folded string. It joins the following indented lines into a single command, which makes long commands easier to read.
#### Preview Step
@@ -545,9 +545,9 @@ jobs:
echo "### Preview: $COUNT updates pending" >> "$GITHUB_STEP_SUMMARY"
```
-- `id: preview` — Gives this step a name so other steps can reference its outputs.
-- `$GITHUB_OUTPUT` — A special file provided by GitHub Actions. Writing `key=value` to it makes the value available to later steps via `steps.preview.outputs.key`. Here, we write the update count so the next step can decide whether to proceed.
-- `$GITHUB_STEP_SUMMARY` — Another special file. Text written here (in Markdown format) appears as a summary on the workflow run page, making it easy to see results at a glance without digging through logs.
+- `id: preview`: Gives this step a name so other steps can reference its outputs.
+- `$GITHUB_OUTPUT`: A special file provided by GitHub Actions. Writing `key=value` to it makes the value available to later steps via `steps.preview.outputs.key`. Here, we write the update count so the next step can decide whether to proceed.
+- `$GITHUB_STEP_SUMMARY`: Another special file. Text written here (in Markdown format) appears as a summary on the workflow run page, making it easy to see results at a glance without digging through logs.
#### Apply Step (Conditional)
@@ -559,7 +559,7 @@ jobs:
--confirm --force --wait --json
```
-- `if:` — Makes this step conditional. It only runs when the preview step found updates to apply. If the count is `0`, this step is skipped entirely, and you will see it greyed out in the workflow log.
+- `if:`: Makes this step conditional. It only runs when the preview step found updates to apply. If the count is `0`, this step is skipped entirely, and you will see it greyed out in the workflow log.
- The flags are the same as in the bash script: `--confirm` to execute for real, `--force` to skip the interactive prompt, `--wait` to block until done, and `--json` for machine-readable output.
#### Verify Step
@@ -598,7 +598,7 @@ jobs:
with:
node-version: '20'
- - run: npm install -g mainwpctl
+ - run: npm install -g @mainwp/control
- name: Login
run: >
@@ -686,7 +686,7 @@ mainwpctl abilities run run-updates-v1 --confirm --force --wait --wait-timeout 6
This increases the timeout to 10 minutes (600 seconds).
-If the command still times out, the updates are not cancelled — they continue running on the MainWP server. The CLI simply stops waiting. You can check the progress of a running job with:
+If the command still times out, the updates are not cancelled. They continue running on the MainWP server. The CLI stops waiting. You can check the progress of a running job with:
```bash
mainwpctl jobs watch
@@ -696,7 +696,7 @@ Replace `` with the job ID from the timeout output. This command connect
### Partial Update Failures
-Some individual updates may fail — for example, a plugin may be incompatible with the current WordPress version, or a site may be temporarily unreachable. The command still exits successfully (exit code 0) as long as the overall operation completed.
+Some individual updates may fail. For example, a plugin may be incompatible with the current WordPress version, or a site may be temporarily unreachable. The command still exits successfully (exit code 0) as long as the overall operation completed.
To see which updates failed, check the `results` array in the JSON output. Each entry includes a `status` field indicating whether that particular update succeeded or failed.
@@ -710,10 +710,10 @@ mainwpctl abilities run list-updates-v1 --json
This is normal and can happen for two reasons:
-1. **Some updates failed** — Check the JSON output from the apply step for failure details.
-2. **New updates appeared during the run** — If a plugin released a new version while your batch was running, it will show up as a new pending update.
+1. **Some updates failed.** Check the JSON output from the apply step for failure details.
+2. **New updates appeared during the run.** If a plugin released a new version while your batch was running, it will show up as a new pending update.
-In either case, you can simply re-run the script to apply the remaining updates.
+In either case, re-run the script to apply the remaining updates.
### `set -e` Causes the Script to Exit Unexpectedly
@@ -730,6 +730,6 @@ To debug, run each command from the script individually in your terminal to find
If the Login step fails in your GitHub Actions workflow:
1. **Verify all three secrets are set.** Go to your repository's **Settings** > **Secrets and variables** > **Actions** and confirm that `DASHBOARD_URL`, `DASHBOARD_USER`, and `MAINWP_APP_PASSWORD` are all listed.
-2. **Check the Dashboard URL.** It must include the `https://` prefix (e.g., `https://dashboard.example.com`, not just `dashboard.example.com`).
+2. **Check the Dashboard URL.** It must include the `https://` prefix (e.g., `https://dashboard.example.com`, not `dashboard.example.com`).
3. **Verify the Application Password.** Go back to your WordPress Dashboard profile and check the Application Passwords section. If the password was revoked or you are unsure of the value, delete it and create a new one (repeat Step 1), then update the `MAINWP_APP_PASSWORD` secret in GitHub.
4. **Check that MainWP Dashboard is reachable.** The GitHub Actions runner connects from the public internet. If your Dashboard is behind a firewall or VPN, the runner will not be able to reach it. You may need to allowlist GitHub Actions IP ranges or use a self-hosted runner.
diff --git a/docs/workflows/plugin-deployment-verification.md b/docs/workflows/plugin-deployment-verification.md
index e910e67..ae6648b 100644
--- a/docs/workflows/plugin-deployment-verification.md
+++ b/docs/workflows/plugin-deployment-verification.md
@@ -27,7 +27,7 @@ Before you start, make sure you have:
| **MainWP Dashboard 6 or later**, installed and running | MainWP Control communicates with the Dashboard REST API, which requires Dashboard 6+. |
| **WordPress admin access** to your Dashboard site | You need to create an Application Password (explained below). |
| **Node.js 20 or later** installed on your local machine | MainWP Control is a Node.js application. You only need Node.js locally for the initial verification steps; the GitHub Actions runner provides its own. |
-| **A GitHub repository** | Any repository works. The workflow does not need your application code -- it just needs a place to live. You can use an existing repo or create a new empty one. |
+| **A GitHub repository** | Any repository works. The workflow does not need your application code, it only needs a place to live. You can use an existing repo or create a new empty one. |
If you do not have Node.js installed, visit [https://nodejs.org](https://nodejs.org) and download the **LTS** version (20 or later). The installer handles everything.
@@ -35,7 +35,7 @@ If you do not have Node.js installed, visit [https://nodejs.org](https://nodejs.
## Step 1: Create an Application Password
-An **Application Password** is a special password that WordPress generates for external tools. It lets MainWP Control authenticate with your Dashboard without using your regular login password. Application Passwords can be revoked individually, so if you ever want to cut off access you can delete just this password without changing your main credentials.
+An **Application Password** is a special password that WordPress generates for external tools. It lets MainWP Control authenticate with your Dashboard without using your regular login password. Application Passwords can be revoked individually, so if you ever want to cut off access you can delete this password alone without changing your main credentials.
1. Log in to your WordPress Dashboard site (the site where MainWP Dashboard is installed).
2. In the left sidebar, go to **Users** then click **Your Profile** (or **Profile**).
@@ -62,7 +62,7 @@ Open a **terminal**. On macOS, open the built-in Terminal app (search for "Termi
Install MainWP Control globally using npm (npm is the package manager that comes with Node.js):
```bash
-npm install -g mainwpctl
+npm install -g @mainwp/control
```
This downloads MainWP Control and makes the `mainwpctl` command available anywhere on your machine.
@@ -230,14 +230,14 @@ jobs:
node-version: '20'
- name: Install mainwpctl
- run: npm install -g mainwpctl
+ run: npm install -g @mainwp/control
```
- `jobs:` defines the work to perform. A workflow can have multiple jobs, but this one only needs one, called `verify`.
- `runs-on: ubuntu-latest` tells GitHub to run this job on a fresh Ubuntu Linux virtual machine. GitHub provides these machines for free (with usage limits for public repositories).
- `steps:` lists the actions to perform, in order.
- `actions/setup-node@v4` is a prebuilt action from GitHub that installs Node.js. The `node-version: '20'` line tells it to install version 20.
-- The second step installs MainWP Control globally, just like you did on your local machine in Step 2.
+- The second step installs MainWP Control globally, the same way you did on your local machine in Step 2.
---
@@ -286,7 +286,7 @@ This is the core of the workflow. Here is what each piece does:
- **`mainwpctl abilities run list-sites-v1 --json`** calls the MainWP API to fetch a list of all connected sites. The `--json` flag tells mainwpctl to output structured JSON instead of human-readable text.
-- **`jq -r '.data.data.items[].id'`** extracts just the site IDs from the JSON response. `jq` is a command-line JSON processor (it comes pre-installed on GitHub's Ubuntu runners). The `-r` flag outputs raw text without quotes. `.data.data.items[].id` is a jq filter that means "from the outer `data` object, navigate into the inner `data` object, get the `items` array, and for each element, extract the `id` field."
+- **`jq -r '.data.data.items[].id'`** extracts the site IDs from the JSON response. `jq` is a command-line JSON processor (it comes pre-installed on GitHub's Ubuntu runners). The `-r` flag outputs raw text without quotes. `.data.data.items[].id` is a jq filter that means "from the outer `data` object, navigate into the inner `data` object, get the `items` array, and for each element, extract the `id` field."
- **`| while read SITE_ID; do ... done`** is a shell loop. The `|` (pipe) sends the list of site IDs into the loop, which processes them one at a time. Each iteration stores one site ID in the variable `SITE_ID`.
@@ -328,7 +328,7 @@ jobs:
node-version: '20'
- name: Install mainwpctl
- run: npm install -g mainwpctl
+ run: npm install -g @mainwp/control
- name: Authenticate
run: >
diff --git a/package-lock.json b/package-lock.json
index 10d0e14..a88ee2e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "mainwpctl",
+ "name": "@mainwp/control",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "mainwpctl",
+ "name": "@mainwp/control",
"version": "1.0.0",
"license": "GPL-3.0-or-later",
"dependencies": {
@@ -962,14 +962,14 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
- "version": "3.972.13",
- "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.13.tgz",
- "integrity": "sha512-I/+BMxM4WE/6xL0tyV7tAUDOAXmyw/va1oGr/eSly43HmLUcD1G+v96vEKAA8VoLcZ03ZQo/PWzjmN9zQErqPQ==",
+ "version": "3.972.15",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.15.tgz",
+ "integrity": "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.1",
- "fast-xml-parser": "5.5.6",
+ "fast-xml-parser": "5.5.8",
"tslib": "^2.6.2"
},
"engines": {
@@ -5594,9 +5594,9 @@
}
},
"node_modules/fast-xml-parser": {
- "version": "5.5.6",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz",
- "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==",
+ "version": "5.5.8",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
+ "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
"dev": true,
"funding": [
{
@@ -5607,8 +5607,8 @@
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
- "path-expression-matcher": "^1.1.3",
- "strnum": "^2.1.2"
+ "path-expression-matcher": "^1.2.0",
+ "strnum": "^2.2.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -7088,9 +7088,9 @@
}
},
"node_modules/path-expression-matcher": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
- "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
+ "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
"dev": true,
"funding": [
{
diff --git a/package.json b/package.json
index 8f49c72..eac4704 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
- "name": "mainwpctl",
+ "name": "@mainwp/control",
"version": "1.0.0",
"description": "Command-line interface for scripting and automating MainWP Dashboard operations",
"type": "module",
@@ -63,6 +63,9 @@
"scripting",
"mainwp-control"
],
+ "publishConfig": {
+ "access": "public"
+ },
"license": "GPL-3.0-or-later",
"homepage": "https://github.com/mainwp/mainwp-control",
"repository": {
@@ -77,9 +80,11 @@
"@oclif/plugin-autocomplete": "~3.2.39",
"@oclif/plugin-help": "~6.0.0",
"ajv": "^8.18.0",
- "keytar": "~7.9.0",
"undici": "^7.24.0"
},
+ "optionalDependencies": {
+ "keytar": "~7.9.0"
+ },
"devDependencies": {
"@oclif/test": "^4.0.0",
"@types/node": "^20.0.0",
diff --git a/src/__tests__/e2e/login-abilities-flow.test.ts b/src/__tests__/e2e/login-abilities-flow.test.ts
index a040d92..e3d644a 100644
--- a/src/__tests__/e2e/login-abilities-flow.test.ts
+++ b/src/__tests__/e2e/login-abilities-flow.test.ts
@@ -401,6 +401,60 @@ describe('E2E: Login → Abilities Flow', () => {
});
});
+ // ==========================================================================
+ // Keychain Timeout Tests
+ // ==========================================================================
+
+ describe('Keychain Timeout Handling', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('set() returns failure when keytar hangs', async () => {
+ // Simulate a keytar call that never resolves (blocked on system dialog)
+ mockKeytarSetPassword.mockReturnValue(new Promise(() => {}));
+
+ const testKeychain = new Keychain();
+ const resultPromise = testKeychain.set('test-profile', 'password');
+
+ // Advance past the 5s timeout
+ await vi.advanceTimersByTimeAsync(5_000);
+
+ const result = await resultPromise;
+ expect(result.stored).toBe(false);
+ expect(result.error).toContain('timed out');
+ });
+
+ it('get() falls back to env var when keytar hangs', async () => {
+ setEnvVar('MAINWP_APP_PASSWORD', 'env-password');
+ mockKeytarGetPassword.mockReturnValue(new Promise(() => {}));
+
+ const testKeychain = new Keychain();
+ const resultPromise = testKeychain.get('test-profile');
+
+ await vi.advanceTimersByTimeAsync(5_000);
+
+ const result = await resultPromise;
+ expect(result).toBe('env-password');
+ });
+
+ it('delete() completes silently when keytar hangs', async () => {
+ mockKeytarDeletePassword.mockReturnValue(new Promise(() => {}));
+
+ const testKeychain = new Keychain();
+ const resultPromise = testKeychain.delete('test-profile');
+
+ await vi.advanceTimersByTimeAsync(5_000);
+
+ await expect(resultPromise).resolves.toBeUndefined();
+ });
+ });
+
// ==========================================================================
// Profile Management Tests
// ==========================================================================
diff --git a/src/__tests__/process/fixtures/cli-runner.ts b/src/__tests__/process/fixtures/cli-runner.ts
index ab01eba..a1708e5 100644
--- a/src/__tests__/process/fixtures/cli-runner.ts
+++ b/src/__tests__/process/fixtures/cli-runner.ts
@@ -56,6 +56,9 @@ export async function runCLI(
// Process tests use a local mock HTTP server; opt in explicitly so
// runtime defaults can remain HTTPS-first.
MAINWP_ALLOW_HTTP: '1',
+ // Skip native keytar — process tests run with isolated HOME where
+ // macOS Keychain access is slow/unavailable.
+ MAINWPCTL_NO_KEYTAR: '1',
// Spread any extra env
...options.env,
};
diff --git a/src/chat/chat-engine.ts b/src/chat/chat-engine.ts
index 8214d5e..0e41e23 100644
--- a/src/chat/chat-engine.ts
+++ b/src/chat/chat-engine.ts
@@ -40,6 +40,7 @@ import {
} from '../core/safety-controller.js';
import { abilityToTool } from './providers/provider.js';
import { logDestructiveActionSafe } from '../utils/audit-logger.js';
+import { getInputSanitizer } from '../validation/input-sanitizer.js';
/**
* Chat response types
@@ -287,7 +288,7 @@ export class ChatEngine {
success: result.success,
};
if (result.error?.message) {
- executionAudit.error = result.error.message;
+ executionAudit.error = getInputSanitizer().sanitizeErrorMessage(result.error.message);
}
await logDestructiveActionSafe({
abilityName: preview.ability.name,
diff --git a/src/chat/providers/provider-fetch.ts b/src/chat/providers/provider-fetch.ts
index a305cbd..da89ed5 100644
--- a/src/chat/providers/provider-fetch.ts
+++ b/src/chat/providers/provider-fetch.ts
@@ -5,6 +5,8 @@
* combined abort signals, JSON POST, error handling.
*/
+import { stripControlChars } from '../../utils/terminal-sanitizer.js';
+
export async function makeProviderRequest(options: {
url: string;
headers: Record;
@@ -29,8 +31,12 @@ export async function makeProviderRequest(options: {
});
if (!response.ok) {
- const error = await response.text();
- throw new Error(`${options.providerName} API error: ${response.status} ${error}`);
+ const errorText = await response.text();
+ // SECURITY: Strip control characters and truncate to prevent exfiltration
+ // of large payloads from untrusted API error bodies
+ const sanitized = stripControlChars(errorText);
+ const truncated = sanitized.length > 500 ? sanitized.slice(0, 500) + '...' : sanitized;
+ throw new Error(`${options.providerName} API error: ${response.status} ${truncated}`);
}
return (await response.json()) as T;
diff --git a/src/commands/abilities/run.ts b/src/commands/abilities/run.ts
index 7723030..638d955 100644
--- a/src/commands/abilities/run.ts
+++ b/src/commands/abilities/run.ts
@@ -9,6 +9,7 @@
import { Args, Flags } from '@oclif/core';
import { readFile } from 'node:fs/promises';
+import { resolve } from 'node:path';
import { BaseCommand, commonFlags } from '../../lib/base-command.js';
import {
formatSuccess,
@@ -130,7 +131,9 @@ export default class AbilitiesRun extends BaseCommand {
// Validate input against schema if available
if (ability.input_schema) {
- schemaValidator.validateOrThrow(input, ability.input_schema, ability.name);
+ const validated = schemaValidator.validateOrThrow(input, ability.input_schema, ability.name);
+ // Use coerced values (type coercion, defaults applied) from validated clone
+ input = validated.coerced ?? input;
}
// Safety validation BEFORE any network call
@@ -287,7 +290,7 @@ export default class AbilitiesRun extends BaseCommand {
success: result.success,
};
if (result.error?.message) {
- executionResult.error = result.error.message;
+ executionResult.error = getInputSanitizer().sanitizeErrorMessage(result.error.message);
}
// Log audit entry (fire-and-forget, covers both success and failure)
@@ -490,8 +493,14 @@ export default class AbilitiesRun extends BaseCommand {
): Promise {
// --input-file takes priority when provided
if (inputFilePath) {
+ // SECURITY: Reject null bytes before any path processing
+ if (inputFilePath.includes('\0')) {
+ throw new InputError('Invalid file path: contains null bytes');
+ }
+ const resolvedPath = resolve(inputFilePath);
+
try {
- return await readFile(inputFilePath, 'utf-8');
+ return await readFile(resolvedPath, 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new InputError(`Input file not found: ${inputFilePath}`);
diff --git a/src/commands/jobs/watch.ts b/src/commands/jobs/watch.ts
index 0eefa88..dd02722 100644
--- a/src/commands/jobs/watch.ts
+++ b/src/commands/jobs/watch.ts
@@ -16,6 +16,7 @@ import {
formatProgressBar,
formatElapsed,
} from '../../output/formatter.js';
+import { safeString } from '../../utils/terminal-sanitizer.js';
import {
type BatchManager,
type JobStatus,
@@ -301,10 +302,10 @@ export default class JobsWatch extends BaseCommand {
for (const item of preview) {
if (typeof item === 'object' && item !== null) {
const obj = item as Record;
- const label = obj['name'] ?? obj['url'] ?? obj['id'] ?? JSON.stringify(obj);
+ const label = safeString(obj['name'] ?? obj['url'] ?? obj['id'] ?? JSON.stringify(obj));
lines.push(` - ${label}`);
} else {
- lines.push(` - ${item}`);
+ lines.push(` - ${safeString(item)}`);
}
}
@@ -319,10 +320,10 @@ export default class JobsWatch extends BaseCommand {
lines.push(formatHeading('Errors:'));
for (const error of status.errors) {
- const prefix = error.code ? `[${error.code}] ` : '';
- lines.push(formatWarning(` ${prefix}${error.message}`));
+ const prefix = error.code ? `[${safeString(error.code)}] ` : '';
+ lines.push(formatWarning(` ${prefix}${safeString(error.message)}`));
if (error.item) {
- lines.push(` Item: ${JSON.stringify(error.item)}`);
+ lines.push(` Item: ${safeString(JSON.stringify(error.item))}`);
}
}
}
diff --git a/src/config/keychain.ts b/src/config/keychain.ts
index a71d2a7..214f6af 100644
--- a/src/config/keychain.ts
+++ b/src/config/keychain.ts
@@ -21,6 +21,26 @@ const SERVICE_NAME = 'mainwpctl';
*/
const ENV_VAR = 'MAINWP_APP_PASSWORD';
+/**
+ * Timeout for keytar operations (ms). If macOS shows a blocking keychain
+ * dialog, this prevents the CLI from hanging indefinitely.
+ */
+const KEYTAR_TIMEOUT_MS = 5_000;
+
+function withTimeout(promise: Promise, ms: number): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error('Keychain access timed out — the system keychain may be locked or unavailable')),
+ ms,
+ );
+ timer.unref();
+ promise.then(
+ (val) => { clearTimeout(timer); resolve(val); },
+ (err) => { clearTimeout(timer); reject(err); },
+ );
+ });
+}
+
/**
* Keytar module (lazy loaded)
*/
@@ -39,8 +59,26 @@ async function loadKeytar(): Promise {
return null;
}
+ // Allow explicit opt-out for CI/containers where native keychain is unavailable
+ if (process.env['MAINWPCTL_NO_KEYTAR'] === '1') {
+ keytarAvailable = false;
+ return null;
+ }
+
try {
- keytar = await import('keytar');
+ const mod = await import('keytar');
+ // CJS/ESM interop: on newer Node versions, CJS exports are nested under .default.
+ // Check for the expected API on mod first; only unwrap .default if needed.
+ keytar = typeof mod.setPassword === 'function'
+ ? mod
+ : typeof (mod as any).default?.setPassword === 'function'
+ ? (mod as any).default
+ : undefined;
+
+ if (!keytar) {
+ keytarAvailable = false;
+ return null;
+ }
keytarAvailable = true;
return keytar;
} catch {
@@ -83,7 +121,7 @@ export class Keychain {
if (kt) {
try {
- await kt.setPassword(SERVICE_NAME, profileName, password);
+ await withTimeout(kt.setPassword(SERVICE_NAME, profileName, password), KEYTAR_TIMEOUT_MS);
return { stored: true, location: 'keychain' };
} catch (error) {
const errorMessage = (error as Error).message;
@@ -111,7 +149,7 @@ export class Keychain {
if (kt) {
try {
- const password = await kt.getPassword(SERVICE_NAME, profileName);
+ const password = await withTimeout(kt.getPassword(SERVICE_NAME, profileName), KEYTAR_TIMEOUT_MS);
if (password) {
return password;
}
@@ -137,9 +175,11 @@ export class Keychain {
if (kt) {
try {
- await kt.deletePassword(SERVICE_NAME, profileName);
- } catch {
- // Ignore delete failures
+ await withTimeout(kt.deletePassword(SERVICE_NAME, profileName), KEYTAR_TIMEOUT_MS);
+ } catch (error) {
+ if (process.stderr.isTTY) {
+ console.error(`Warning: Failed to remove credentials from keychain: ${(error as Error).message}`);
+ }
}
}
}
diff --git a/src/core/batch-manager.ts b/src/core/batch-manager.ts
index d76c973..b05a575 100644
--- a/src/core/batch-manager.ts
+++ b/src/core/batch-manager.ts
@@ -244,49 +244,41 @@ export class BatchManager {
}
/**
- * Normalize API response to JobStatus
+ * Normalize API response to JobStatus, unwrapping success envelope if present
*/
private normalizeJobStatus(data: unknown): JobStatus {
- // Handle the response format from get-batch-job-status-v1
if (typeof data !== 'object' || data === null) {
throw new APIError('INVALID_RESPONSE', 'Invalid job status response');
}
const response = data as Record;
- // Check for success envelope
+ // Unwrap success envelope if present
+ let fields = response;
if ('success' in response && 'data' in response) {
- const innerData = response['data'] as Record;
- return this.extractJobStatus(innerData);
+ const inner = response['data'];
+ if (typeof inner !== 'object' || inner === null || Array.isArray(inner)) {
+ throw new APIError('INVALID_RESPONSE', 'Invalid job status data in success envelope');
+ }
+ fields = inner as Record;
}
- return this.extractJobStatus(response);
- }
-
- /**
- * Extract job status from response data
- */
- private extractJobStatus(data: Record): JobStatus {
- const id = String(data['job_id'] ?? data['id'] ?? '');
-
- // Validate that we have a job ID
+ const id = String(fields['job_id'] ?? fields['id'] ?? '');
if (!id) {
throw new APIError('INVALID_RESPONSE', 'Job status response missing job ID');
}
- const status: JobStatus = {
+ return {
id,
- status: this.parseJobStatus(data['status']),
- progress: typeof data['progress'] === 'number' ? data['progress'] : undefined,
- total: typeof data['total'] === 'number' ? data['total'] : undefined,
- processed: typeof data['processed'] === 'number' ? data['processed'] : undefined,
- results: Array.isArray(data['results']) ? data['results'] : undefined,
- errors: this.parseJobErrors(data['errors']),
- created_at: typeof data['created_at'] === 'string' ? data['created_at'] : undefined,
- completed_at: typeof data['completed_at'] === 'string' ? data['completed_at'] : undefined,
+ status: this.parseJobStatus(fields['status']),
+ progress: typeof fields['progress'] === 'number' ? fields['progress'] : undefined,
+ total: typeof fields['total'] === 'number' ? fields['total'] : undefined,
+ processed: typeof fields['processed'] === 'number' ? fields['processed'] : undefined,
+ results: Array.isArray(fields['results']) ? fields['results'] : undefined,
+ errors: this.parseJobErrors(fields['errors']),
+ created_at: typeof fields['created_at'] === 'string' ? fields['created_at'] : undefined,
+ completed_at: typeof fields['completed_at'] === 'string' ? fields['completed_at'] : undefined,
};
-
- return status;
}
/**
diff --git a/src/core/http-client.test.ts b/src/core/http-client.test.ts
index 7c98a96..6e850f2 100644
--- a/src/core/http-client.test.ts
+++ b/src/core/http-client.test.ts
@@ -661,6 +661,120 @@ describe('HttpClient buildUrl Origin Validation', () => {
});
});
+describe('HttpClient Prototype Pollution Protection', () => {
+ const baseConfig: HttpClientConfig = {
+ baseUrl: 'https://dashboard.example.com',
+ username: 'admin',
+ appPassword: 'test-password',
+ };
+
+ beforeEach(() => {
+ mockFetch.mockReset();
+ });
+
+ it('strips __proto__ keys from API response JSON', async () => {
+ mockFetch.mockResolvedValueOnce({
+ status: 200,
+ ok: true,
+ statusText: 'OK',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ text: () => Promise.resolve('{"name":"test","__proto__":{"isAdmin":true}}'),
+ });
+
+ const client = createHttpClient(baseConfig);
+ const response = await client.get('/test');
+ const data = response.data as Record;
+
+ expect(data.name).toBe('test');
+ // __proto__ key should be stripped
+ expect(data).not.toHaveProperty('__proto__');
+ // Prototype should not be polluted
+ expect(({} as any).isAdmin).toBeUndefined();
+ });
+
+ it('strips constructor keys from API response JSON', async () => {
+ mockFetch.mockResolvedValueOnce({
+ status: 200,
+ ok: true,
+ statusText: 'OK',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ text: () => Promise.resolve('{"name":"test","constructor":{"prototype":{"polluted":true}}}'),
+ });
+
+ const client = createHttpClient(baseConfig);
+ const response = await client.get('/test');
+ const data = response.data as Record;
+
+ expect(data.name).toBe('test');
+ expect(data).not.toHaveProperty('constructor');
+ });
+});
+
+describe('HttpClient sanitizeErrorData — Extended Sensitive Fields', () => {
+ const baseConfig: HttpClientConfig = {
+ baseUrl: 'https://dashboard.example.com',
+ username: 'admin',
+ appPassword: 'test-password',
+ };
+
+ beforeEach(() => {
+ mockFetch.mockReset();
+ });
+
+ async function triggerErrorWithData(data: unknown): Promise {
+ mockFetch.mockResolvedValueOnce({
+ status: 500,
+ ok: false,
+ statusText: 'Internal Server Error',
+ headers: new Headers({ 'content-type': 'application/json' }),
+ text: () => Promise.resolve(JSON.stringify(data)),
+ });
+
+ const client = createHttpClient(baseConfig);
+ try {
+ await client.get('/test');
+ throw new Error('Expected error to be thrown');
+ } catch (error) {
+ return error as Error;
+ }
+ }
+
+ it('redacts appPassword and apiKey fields', async () => {
+ const error = await triggerErrorWithData({
+ appPassword: 'secret-app-pw',
+ apiKey: 'sk-1234',
+ message: 'visible',
+ });
+
+ const errorData = (error as any).details;
+ expect(errorData.appPassword).toBe('[REDACTED]');
+ expect(errorData.apiKey).toBe('[REDACTED]');
+ expect(errorData.message).toBe('visible');
+ });
+
+ it('redacts api_key and app_password fields', async () => {
+ const error = await triggerErrorWithData({
+ api_key: 'key-5678',
+ app_password: 'pw-9999',
+ });
+
+ const errorData = (error as any).details;
+ expect(errorData.api_key).toBe('[REDACTED]');
+ expect(errorData.app_password).toBe('[REDACTED]');
+ });
+
+ it('redacts bearer and credential fields', async () => {
+ const error = await triggerErrorWithData({
+ bearer: 'tok-abc',
+ credential: 'cred-xyz',
+ });
+
+ const errorData = (error as any).details;
+ expect(errorData.bearer).toBe('[REDACTED]');
+ expect(errorData.credential).toBe('[REDACTED]');
+ });
+});
+
describe('HttpClient Manual Redirect Mode', () => {
beforeEach(() => {
mockFetch.mockReset();
diff --git a/src/core/http-client.ts b/src/core/http-client.ts
index 8d5f879..5ece8bd 100644
--- a/src/core/http-client.ts
+++ b/src/core/http-client.ts
@@ -5,9 +5,13 @@
* INVARIANT: All HTTP requests MUST go through this module.
*/
+import { createRequire } from 'node:module';
import { Agent } from 'undici';
import { NetworkError, TLSError, APIError, AuthError } from '../utils/errors.js';
+const require = createRequire(import.meta.url);
+const { version: PKG_VERSION } = require('../../package.json') as { version: string };
+
/**
* Request options
*/
@@ -218,7 +222,14 @@ export class HttpClient {
let data: T;
try {
- data = text ? (JSON.parse(text) as T) : ({} as T);
+ // SECURITY: Strip __proto__ and constructor keys to prevent prototype
+ // pollution from untrusted API responses
+ data = text ? (JSON.parse(text, (key, value) => {
+ if (key === '__proto__' || key === 'constructor') {
+ return undefined;
+ }
+ return value;
+ }) as T) : ({} as T);
} catch {
// If not JSON, wrap as string
data = text as unknown as T;
@@ -329,7 +340,7 @@ export class HttpClient {
Authorization: this.authHeader,
'Content-Type': 'application/json',
Accept: 'application/json',
- 'User-Agent': 'mainwpctl/1.0.0',
+ 'User-Agent': `mainwpctl/${PKG_VERSION}`,
...custom,
};
}
@@ -382,51 +393,57 @@ export class HttpClient {
}
}
+ /** Error code → typed error mappings */
+ private static readonly ERROR_CODE_MAP: Record Error> = {
+ ECONNREFUSED: () => new NetworkError(
+ 'Connection refused. Is the Dashboard running?',
+ undefined,
+ 'Verify the Dashboard is running and the URL is correct'
+ ),
+ ENOTFOUND: () => new NetworkError(
+ 'Host not found. Check the Dashboard URL.',
+ undefined,
+ 'Check the Dashboard URL in your profile with `mainwpctl config show`'
+ ),
+ CERT_HAS_EXPIRED: () => new TLSError(
+ 'SSL certificate error. Use --skip-ssl-verify if needed.',
+ undefined,
+ 'Use --skip-ssl-verify flag if using self-signed certificates (not recommended for production)'
+ ),
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: () => new TLSError(
+ 'SSL certificate error. Use --skip-ssl-verify if needed.',
+ undefined,
+ 'Use --skip-ssl-verify flag if using self-signed certificates (not recommended for production)'
+ ),
+ };
+
+ /** Known MainWPCTL error names that should pass through unchanged */
+ private static readonly KNOWN_ERROR_NAMES = new Set([
+ 'NetworkError', 'TLSError', 'APIError', 'AuthError',
+ ]);
+
/**
* Normalize errors to MainWPCTLError types
*/
private normalizeError(error: unknown): Error {
- if (error instanceof Error) {
- // Check for abort/timeout
- if (error.name === 'AbortError') {
- return new NetworkError(
- 'Request timed out',
- undefined,
- 'Increase timeout with --timeout flag or check network connection'
- );
- }
+ if (!(error instanceof Error)) {
+ return new NetworkError(String(error));
+ }
- // Check for network errors (check both error and cause chain)
- const errorCode = this.extractErrorCode(error);
- if (errorCode) {
- if (errorCode === 'ECONNREFUSED') {
- return new NetworkError(
- 'Connection refused. Is the Dashboard running?',
- undefined,
- 'Verify the Dashboard is running and the URL is correct'
- );
- }
- if (errorCode === 'ENOTFOUND') {
- return new NetworkError(
- 'Host not found. Check the Dashboard URL.',
- undefined,
- 'Check the Dashboard URL in your profile with `mainwpctl config show`'
- );
- }
- if (errorCode === 'CERT_HAS_EXPIRED' || errorCode === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
- return new TLSError(
- 'SSL certificate error. Use --skip-ssl-verify if needed.',
- undefined,
- 'Use --skip-ssl-verify flag if using self-signed certificates (not recommended for production)'
- );
- }
- }
+ if (error.name === 'AbortError') {
+ return new NetworkError(
+ 'Request timed out',
+ undefined,
+ 'Increase timeout with --timeout flag or check network connection'
+ );
+ }
- // Already a MainWPCTL error or known network error type
- if (error.name === 'NetworkError' || error.name === 'TLSError' ||
- error.name === 'APIError' || error.name === 'AuthError') {
- return error;
- }
+ const errorCode = this.extractErrorCode(error);
+ const mapped = errorCode ? HttpClient.ERROR_CODE_MAP[errorCode] : undefined;
+ if (mapped) return mapped();
+
+ if (HttpClient.KNOWN_ERROR_NAMES.has(error.name)) {
+ return error;
}
return new NetworkError(String(error));
@@ -457,10 +474,17 @@ export class HttpClient {
if (Array.isArray(data)) return data.map(item => this.sanitizeErrorData(item));
const sanitized: Record = {};
- const sensitiveFields = ['password', 'token', 'secret', 'authorization', 'cookie'];
+ // Substring matching catches camelCase, snake_case, and header variants
+ // (e.g., accessToken, private_key, set-cookie, refreshToken)
+ const sensitiveSubstrings = [
+ 'password', 'token', 'secret', 'authorization', 'cookie',
+ 'apikey', 'api_key', 'bearer', 'credential', 'private_key',
+ 'signing_key',
+ ];
for (const [key, value] of Object.entries(data)) {
- if (sensitiveFields.includes(key.toLowerCase())) {
+ const keyLower = key.toLowerCase();
+ if (sensitiveSubstrings.some(s => keyLower.includes(s))) {
sanitized[key] = '[REDACTED]';
} else {
sanitized[key] = this.sanitizeErrorData(value);
diff --git a/src/core/safety-controller.test.ts b/src/core/safety-controller.test.ts
index 4cf83d9..ecfa569 100644
--- a/src/core/safety-controller.test.ts
+++ b/src/core/safety-controller.test.ts
@@ -12,7 +12,6 @@
import { describe, it, expect, vi } from 'vitest';
import {
SafetyController,
- createSafetyController,
} from './safety-controller.js';
import { MutualExclusionError, ConfirmationRequiredError } from '../utils/errors.js';
import type { Ability } from './abilities-executor.js';
@@ -43,7 +42,7 @@ describe('Golden Test: Mutual Exclusion (dry_run XOR confirm)', () => {
let controller: SafetyController;
beforeEach(() => {
- controller = createSafetyController();
+ controller = new SafetyController();
});
/**
@@ -105,7 +104,7 @@ describe('Golden Test: Destructive Preview Requirement', () => {
let controller: SafetyController;
beforeEach(() => {
- controller = createSafetyController();
+ controller = new SafetyController();
});
/**
@@ -162,7 +161,7 @@ describe('Golden Test: Safety Classification', () => {
let controller: SafetyController;
beforeEach(() => {
- controller = createSafetyController();
+ controller = new SafetyController();
});
/**
@@ -238,7 +237,7 @@ describe('Golden Test: Execution Intent', () => {
let controller: SafetyController;
beforeEach(() => {
- controller = createSafetyController();
+ controller = new SafetyController();
});
it('determines preview intent from dry_run flag', () => {
@@ -266,7 +265,7 @@ describe('Golden Test: Direct Execution Decision', () => {
let controller: SafetyController;
beforeEach(() => {
- controller = createSafetyController();
+ controller = new SafetyController();
});
it('executes readonly abilities directly', () => {
@@ -294,7 +293,7 @@ describe('Annotation Validation (F2)', () => {
let controller: SafetyController;
beforeEach(() => {
- controller = createSafetyController();
+ controller = new SafetyController();
});
it('falls back to safe defaults for non-boolean annotation values', () => {
@@ -363,7 +362,7 @@ describe('M6: Known-destructive pattern defense-in-depth', () => {
let controller: SafetyController;
beforeEach(() => {
- controller = createSafetyController();
+ controller = new SafetyController();
});
it('forces destructive classification for delete-* even when API says readonly', () => {
@@ -429,3 +428,21 @@ describe('M6: Known-destructive pattern defense-in-depth', () => {
expect(classification.requiresSafetyFlow).toBe(true);
});
});
+
+describe('ACTION_VERBS substring ordering', () => {
+ it('uses "deactivated" verb for deactivate abilities, not "activated"', () => {
+ const controller = new SafetyController();
+ const ability = createTestAbility('mainwp/deactivate-site-plugins-v1', {
+ destructive: true,
+ });
+ const result = controller.formatPreviewResult(ability, {}, {
+ success: true,
+ data: { affected: [{ id: 1 }] },
+ });
+ expect(result.summary).toContain('deactivated');
+ // Ensure it matched "deactivate" not "activate" — the word before
+ // "activated" must be "de" (i.e., only "deactivated" appears, not
+ // a separate "activated" match)
+ expect(result.summary).not.toMatch(/(? name.includes(k))?.[1] ?? 'affected';
+ if (count === 0) return `No items would be ${action}.`;
+ if (count === 1) return `1 item would be ${action}.`;
return `${count} items would be ${action}.`;
}
- /**
- * Get action verb from ability name
- */
- private getActionVerb(abilityName: string): string {
- const name = abilityName.toLowerCase();
-
- if (name.includes('delete')) return 'deleted';
- if (name.includes('remove')) return 'removed';
- if (name.includes('update')) return 'updated';
- if (name.includes('activate')) return 'activated';
- if (name.includes('deactivate')) return 'deactivated';
- if (name.includes('suspend')) return 'suspended';
- if (name.includes('disconnect')) return 'disconnected';
-
- return 'affected';
- }
-
- /**
- * Get default annotations for abilities without explicit annotations
- */
- private getDefaultAnnotations(): AbilityAnnotations {
- return {
- readonly: false,
- destructive: false,
- idempotent: false,
- };
- }
}
/**
@@ -339,9 +321,3 @@ export function getSafetyController(): SafetyController {
return instance;
}
-/**
- * Create a new safety controller (for testing)
- */
-export function createSafetyController(): SafetyController {
- return new SafetyController();
-}
diff --git a/src/utils/audit-logger.test.ts b/src/utils/audit-logger.test.ts
index a0620d6..489b0a3 100644
--- a/src/utils/audit-logger.test.ts
+++ b/src/utils/audit-logger.test.ts
@@ -2,8 +2,12 @@
* Tests for audit-logger
*/
+import { join } from 'node:path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+/** Platform-aware expected path for audit.log (matches production join()) */
+const MOCK_LOG = join('/mock/config', 'audit.log');
+
// Mock dependencies before importing the module under test
const mockMkdir = vi.fn();
const mockAppendFile = vi.fn();
@@ -38,7 +42,6 @@ vi.mock('../validation/input-sanitizer.js', () => ({
import {
AuditLogger,
- createAuditLogger,
getAuditLogger,
getAuditLogPath,
logDestructiveActionSafe,
@@ -58,7 +61,7 @@ describe('AuditLogger', () => {
mockAccess.mockResolvedValue(undefined);
mockAppendFile.mockResolvedValue(undefined);
- logger = createAuditLogger();
+ logger = new AuditLogger();
});
afterEach(() => {
@@ -67,7 +70,7 @@ describe('AuditLogger', () => {
describe('getAuditLogPath', () => {
it('returns path inside config directory', () => {
- expect(getAuditLogPath()).toBe('/mock/config/audit.log');
+ expect(getAuditLogPath()).toBe(MOCK_LOG);
});
});
@@ -92,7 +95,7 @@ describe('AuditLogger', () => {
expect(mockAppendFile).toHaveBeenCalledTimes(1);
const [path, content] = mockAppendFile.mock.calls[0]!;
- expect(path).toBe('/mock/config/audit.log');
+ expect(path).toBe(MOCK_LOG);
const entry = JSON.parse(content.trim());
expect(entry.timestamp).toBe('2026-03-18T12:00:00.000Z');
@@ -180,7 +183,7 @@ describe('AuditLogger', () => {
await logger.logDestructiveAction(baseInput);
- expect(mockOpen).toHaveBeenCalledWith('/mock/config/audit.log', 'w', 0o600);
+ expect(mockOpen).toHaveBeenCalledWith(MOCK_LOG, 'w', 0o600);
expect(mockFd.close).toHaveBeenCalled();
});
});
@@ -211,14 +214,14 @@ describe('AuditLogger', () => {
});
// Deletes oldest (audit.log.5)
- expect(mockUnlink).toHaveBeenCalledWith('/mock/config/audit.log.5');
+ expect(mockUnlink).toHaveBeenCalledWith(`${MOCK_LOG}.5`);
// Renames 4→5, 3→4, 2→3, 1→2
- expect(mockRename).toHaveBeenCalledWith('/mock/config/audit.log.4', '/mock/config/audit.log.5');
- expect(mockRename).toHaveBeenCalledWith('/mock/config/audit.log.3', '/mock/config/audit.log.4');
- expect(mockRename).toHaveBeenCalledWith('/mock/config/audit.log.2', '/mock/config/audit.log.3');
- expect(mockRename).toHaveBeenCalledWith('/mock/config/audit.log.1', '/mock/config/audit.log.2');
+ expect(mockRename).toHaveBeenCalledWith(`${MOCK_LOG}.4`, `${MOCK_LOG}.5`);
+ expect(mockRename).toHaveBeenCalledWith(`${MOCK_LOG}.3`, `${MOCK_LOG}.4`);
+ expect(mockRename).toHaveBeenCalledWith(`${MOCK_LOG}.2`, `${MOCK_LOG}.3`);
+ expect(mockRename).toHaveBeenCalledWith(`${MOCK_LOG}.1`, `${MOCK_LOG}.2`);
// Renames current → .1
- expect(mockRename).toHaveBeenCalledWith('/mock/config/audit.log', '/mock/config/audit.log.1');
+ expect(mockRename).toHaveBeenCalledWith(MOCK_LOG, `${MOCK_LOG}.1`);
});
it('does not rotate when file does not exist', async () => {
diff --git a/src/utils/audit-logger.ts b/src/utils/audit-logger.ts
index a37a71d..ec76cdc 100644
--- a/src/utils/audit-logger.ts
+++ b/src/utils/audit-logger.ts
@@ -211,12 +211,6 @@ export function getAuditLogger(): AuditLogger {
return instance;
}
-/**
- * Create a new audit logger (for testing)
- */
-export function createAuditLogger(): AuditLogger {
- return new AuditLogger();
-}
/**
* Fire-and-forget wrapper for logDestructiveAction.
diff --git a/src/utils/colors.ts b/src/utils/colors.ts
index af2ec00..89a839f 100644
--- a/src/utils/colors.ts
+++ b/src/utils/colors.ts
@@ -24,7 +24,7 @@ export const colors = {
* Check if we should use colored output
*/
export function useColors(): boolean {
- return process.stdout.isTTY === true && process.env['NO_COLOR'] === undefined;
+ return process.stdout.isTTY === true && !('NO_COLOR' in process.env);
}
/**
diff --git a/src/utils/errors.ts b/src/utils/errors.ts
index 736590e..6b87547 100644
--- a/src/utils/errors.ts
+++ b/src/utils/errors.ts
@@ -189,17 +189,3 @@ export function isMainWPCTLError(error: unknown): error is MainWPCTLError {
return error instanceof MainWPCTLError;
}
-/**
- * Convert any error to a MainWPCTLError
- */
-export function toMainWPCTLError(error: unknown): MainWPCTLError {
- if (isMainWPCTLError(error)) {
- return error;
- }
-
- if (error instanceof Error) {
- return new InternalError(error.message, error);
- }
-
- return new InternalError(String(error));
-}
diff --git a/src/utils/exit-codes.ts b/src/utils/exit-codes.ts
index 9aa15ca..540882d 100644
--- a/src/utils/exit-codes.ts
+++ b/src/utils/exit-codes.ts
@@ -25,18 +25,3 @@ export const ExitCode = {
} as const;
export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode];
-
-/**
- * Get human-readable name for an exit code
- */
-export function exitCodeName(code: ExitCodeValue): string {
- const names: Record = {
- [ExitCode.SUCCESS]: 'SUCCESS',
- [ExitCode.INPUT_ERROR]: 'INPUT_ERROR',
- [ExitCode.AUTH_ERROR]: 'AUTH_ERROR',
- [ExitCode.NETWORK_ERROR]: 'NETWORK_ERROR',
- [ExitCode.API_ERROR]: 'API_ERROR',
- [ExitCode.INTERNAL_ERROR]: 'INTERNAL_ERROR',
- };
- return names[code] ?? 'UNKNOWN';
-}
diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts
index 2555fbc..d1b2608 100644
--- a/src/utils/prompt.ts
+++ b/src/utils/prompt.ts
@@ -15,6 +15,24 @@ export function isInteractive(): boolean {
return process.stdin.isTTY === true && process.stdout.isTTY === true;
}
+/**
+ * Ask a single question via readline, returning the raw answer.
+ * Handles interface creation and cleanup.
+ */
+function ask(promptText: string): Promise {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+
+ return new Promise((resolve) => {
+ rl.question(promptText, (answer) => {
+ rl.close();
+ resolve(answer);
+ });
+ });
+}
+
/**
* Prompt for yes/no confirmation
*
@@ -26,33 +44,18 @@ export async function promptForConfirmation(
question: string,
defaultAnswer = false
): Promise {
- // Non-interactive mode: return default (false = safe)
if (!isInteractive()) {
return defaultAnswer;
}
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
- });
-
const hint = defaultAnswer ? '[Y/n]' : '[y/N]';
const prompt = color('? ', colors.yellow) + question + ' ' + color(hint, colors.dim) + ' ';
- return new Promise((resolve) => {
- rl.question(prompt, (answer) => {
- rl.close();
+ const answer = await ask(prompt);
+ const normalized = answer.trim().toLowerCase();
- const normalized = answer.trim().toLowerCase();
-
- if (normalized === '') {
- resolve(defaultAnswer);
- return;
- }
-
- resolve(normalized === 'y' || normalized === 'yes');
- });
- });
+ if (normalized === '') return defaultAnswer;
+ return normalized === 'y' || normalized === 'yes';
}
/**
@@ -70,20 +73,11 @@ export async function promptForInput(
return defaultValue ?? '';
}
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
- });
-
const defaultHint = defaultValue ? color(` (${defaultValue})`, colors.dim) : '';
const prompt = color('? ', colors.yellow) + question + defaultHint + ' ';
- return new Promise((resolve) => {
- rl.question(prompt, (answer) => {
- rl.close();
- resolve(answer.trim() || defaultValue || '');
- });
- });
+ const answer = await ask(prompt);
+ return answer.trim() || defaultValue || '';
}
/**
diff --git a/src/validation/input-sanitizer.test.ts b/src/validation/input-sanitizer.test.ts
index c07ea84..841be05 100644
--- a/src/validation/input-sanitizer.test.ts
+++ b/src/validation/input-sanitizer.test.ts
@@ -3,10 +3,10 @@
*/
import { describe, it, expect } from 'vitest';
-import { createInputSanitizer } from './input-sanitizer.js';
+import { InputSanitizer } from './input-sanitizer.js';
describe('InputSanitizer — isSensitiveKey', () => {
- const sanitizer = createInputSanitizer();
+ const sanitizer = new InputSanitizer();
it('detects signing_key variants', () => {
expect(sanitizer.isSensitiveKey('signing_key')).toBe(true);
@@ -38,7 +38,7 @@ describe('InputSanitizer — isSensitiveKey', () => {
});
describe('InputSanitizer — redactSensitive', () => {
- const sanitizer = createInputSanitizer();
+ const sanitizer = new InputSanitizer();
it('redacts signing_key and encryption_key fields', () => {
const data = {
diff --git a/src/validation/input-sanitizer.ts b/src/validation/input-sanitizer.ts
index 0bca784..70aeb3a 100644
--- a/src/validation/input-sanitizer.ts
+++ b/src/validation/input-sanitizer.ts
@@ -268,9 +268,3 @@ export function getInputSanitizer(): InputSanitizer {
return instance;
}
-/**
- * Create a new input sanitizer (for testing)
- */
-export function createInputSanitizer(options?: SanitizeOptions): InputSanitizer {
- return new InputSanitizer(options);
-}
diff --git a/src/validation/schema-validator.test.ts b/src/validation/schema-validator.test.ts
new file mode 100644
index 0000000..e933b77
--- /dev/null
+++ b/src/validation/schema-validator.test.ts
@@ -0,0 +1,100 @@
+/**
+ * Tests for Schema Validator
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { SchemaValidator } from './schema-validator.js';
+
+describe('SchemaValidator', () => {
+ let validator: SchemaValidator;
+
+ beforeEach(() => {
+ validator = new SchemaValidator();
+ });
+
+ it('does not mutate the original input object', () => {
+ const input: Record = { count: '5' };
+ const original = { ...input };
+ const schema = {
+ type: 'object',
+ properties: { count: { type: 'number' } },
+ };
+
+ validator.validate(input, schema);
+
+ // Original input must remain unmodified (string '5', not coerced to number 5)
+ expect(input).toEqual(original);
+ expect(typeof input.count).toBe('string');
+ });
+
+ it('returns coerced values in the result', () => {
+ const input: Record = { count: '5' };
+ const schema = {
+ type: 'object',
+ properties: { count: { type: 'number' } },
+ };
+
+ const result = validator.validate(input, schema);
+
+ expect(result.valid).toBe(true);
+ expect(result.coerced).toBeDefined();
+ expect(result.coerced!.count).toBe(5);
+ expect(typeof result.coerced!.count).toBe('number');
+ });
+
+ it('applies schema defaults to the coerced copy, not the original', () => {
+ const input: Record = {};
+ const schema = {
+ type: 'object',
+ properties: {
+ status: { type: 'string', default: 'active' },
+ },
+ };
+
+ const result = validator.validate(input, schema);
+
+ expect(result.valid).toBe(true);
+ expect(result.coerced!.status).toBe('active');
+ // Original must not have the default applied
+ expect(input.status).toBeUndefined();
+ });
+
+ it('validateOrThrow returns coerced data on success', () => {
+ const input: Record = { count: '10' };
+ const schema = {
+ type: 'object',
+ properties: { count: { type: 'number' } },
+ };
+
+ const result = validator.validateOrThrow(input, schema, 'test-ability');
+
+ expect(result.coerced!.count).toBe(10);
+ expect(input.count).toBe('10'); // original unchanged
+ });
+
+ it('validateOrThrow throws on invalid input', () => {
+ const input: Record = { count: 'not-a-number' };
+ const schema = {
+ type: 'object',
+ properties: { count: { type: 'number' } },
+ };
+
+ expect(() => validator.validateOrThrow(input, schema, 'test-ability')).toThrow(
+ 'Invalid input for ability "test-ability"'
+ );
+ });
+
+ it('isValid does not mutate original input', () => {
+ const input: Record = { count: '5' };
+ const original = { ...input };
+ const schema = {
+ type: 'object',
+ properties: { count: { type: 'number' } },
+ };
+
+ const valid = validator.isValid(input, schema);
+
+ expect(valid).toBe(true);
+ expect(input).toEqual(original);
+ });
+});
diff --git a/src/validation/schema-validator.ts b/src/validation/schema-validator.ts
index 401f21e..4f04c6e 100644
--- a/src/validation/schema-validator.ts
+++ b/src/validation/schema-validator.ts
@@ -17,6 +17,8 @@ const Ajv = AjvModule.default ?? AjvModule;
export interface ValidationResult {
valid: boolean;
errors?: ValidationError[];
+ /** Coerced copy of input after AJV applies type coercion and defaults */
+ coerced?: Record;
}
/**
@@ -63,14 +65,16 @@ export class SchemaValidator {
schemaId?: string
): ValidationResult {
const validate = this.getCompiledSchema(schema, schemaId);
- const valid = validate(input);
+ // Clone input so AJV coerceTypes/useDefaults mutates the clone, not the caller's object
+ const coerced = structuredClone(input);
+ const valid = validate(coerced);
if (valid) {
- return { valid: true };
+ return { valid: true, coerced };
}
const errors = this.formatErrors(validate.errors ?? []);
- return { valid: false, errors };
+ return { valid: false, errors, coerced };
}
/**
@@ -85,7 +89,7 @@ export class SchemaValidator {
input: Record,
schema: Record,
abilityName: string
- ): void {
+ ): ValidationResult {
const result = this.validate(input, schema, abilityName);
if (!result.valid && result.errors) {
@@ -96,6 +100,8 @@ export class SchemaValidator {
`Check the ability schema with \`mainwpctl abilities info ${abilityName}\` for required fields and types`
);
}
+
+ return result;
}
/**
@@ -107,7 +113,7 @@ export class SchemaValidator {
schemaId?: string
): boolean {
const validate = this.getCompiledSchema(schema, schemaId);
- return validate(input) as boolean;
+ return validate(structuredClone(input)) as boolean;
}
/**
@@ -211,9 +217,3 @@ export function getSchemaValidator(): SchemaValidator {
return instance;
}
-/**
- * Create a new schema validator (for testing)
- */
-export function createSchemaValidator(): SchemaValidator {
- return new SchemaValidator();
-}
diff --git a/vitest.config.ts b/vitest.config.ts
index 7ddc4e4..ee04806 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -5,6 +5,8 @@ export default defineConfig({
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
+ // Process tests spawn CLI as child process; Windows CI needs extra time
+ testTimeout: 30_000,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],