summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorluisgulo <luisgulo@gmail.com>2025-10-24 18:01:10 +0200
committerluisgulo <luisgulo@gmail.com>2025-10-24 18:01:10 +0200
commit533e79ba959143f0459431a486bfb85c56c72ddc (patch)
tree91974de1bbbdc4c51c76ed591fc5c6e02a3342b6
parent45019c81cfd0fc1d18dce18cdfd5f127c6d61073 (diff)
Releasing code version 1.8.0
-rw-r--r--LICENSE674
-rw-r--r--community_modules/ldap/ldap_ad.sh71
-rw-r--r--community_modules/ldap/ldap_ad.tr.en7
-rw-r--r--community_modules/ldap/ldap_ad.tr.es7
-rw-r--r--community_modules/ldap/ldap_openldap.sh72
-rw-r--r--community_modules/ldap/ldap_openldap.tr.en7
-rw-r--r--community_modules/ldap/ldap_openldap.tr.es7
-rw-r--r--community_modules/security/euvd_check.sh101
-rw-r--r--community_modules/security/euvd_check.tr.en17
-rw-r--r--community_modules/security/euvd_check.tr.es17
-rw-r--r--community_modules/winremote/winremote_check.sh55
-rw-r--r--community_modules/winremote/winremote_check.tr.en6
-rw-r--r--community_modules/winremote/winremote_check.tr.es6
-rw-r--r--community_modules/winremote/winremote_detect.sh65
-rw-r--r--community_modules/winremote/winremote_detect.tr.en10
-rw-r--r--community_modules/winremote/winremote_detect.tr.es10
-rw-r--r--community_modules/winremote/winremote_exec.sh60
-rw-r--r--community_modules/winremote/winremote_exec.tr.en6
-rw-r--r--community_modules/winremote/winremote_exec.tr.es6
-rw-r--r--community_modules/winremote/winremote_exec_winrm.sh74
-rw-r--r--community_modules/winremote/winremote_exec_winrm.tr.en6
-rw-r--r--community_modules/winremote/winremote_exec_winrm.tr.es6
-rw-r--r--core/inventory/groups.yaml9
-rw-r--r--core/inventory/hosts.yaml17
-rw-r--r--core/inventory/vars/all.yaml20
-rw-r--r--core/lib/translate_msg.sh9
-rw-r--r--core/modules/api.sh88
-rw-r--r--core/modules/api.tr.en8
-rw-r--r--core/modules/api.tr.es8
-rw-r--r--core/modules/archive.sh113
-rw-r--r--core/modules/archive.tr.en14
-rw-r--r--core/modules/archive.tr.es14
-rw-r--r--core/modules/blockinfile.sh79
-rw-r--r--core/modules/blockinfile.tr.en7
-rw-r--r--core/modules/blockinfile.tr.es7
-rw-r--r--core/modules/copy.sh65
-rw-r--r--core/modules/copy.tr.en7
-rw-r--r--core/modules/copy.tr.es7
-rw-r--r--core/modules/cron.sh97
-rw-r--r--core/modules/cron.tr.en12
-rw-r--r--core/modules/cron.tr.es12
-rw-r--r--core/modules/docker.sh81
-rw-r--r--core/modules/docker.tr.en10
-rw-r--r--core/modules/docker.tr.es10
-rw-r--r--core/modules/download.sh64
-rw-r--r--core/modules/download.tr.en7
-rw-r--r--core/modules/download.tr.es7
-rw-r--r--core/modules/echo.sh51
-rw-r--r--core/modules/echo.tr.en2
-rw-r--r--core/modules/echo.tr.es2
-rw-r--r--core/modules/facts.sh121
-rw-r--r--core/modules/facts.tr.en5
-rw-r--r--core/modules/facts.tr.es5
-rw-r--r--core/modules/file.sh78
-rw-r--r--core/modules/file.tr.en8
-rw-r--r--core/modules/file.tr.es8
-rw-r--r--core/modules/file_read.sh56
-rw-r--r--core/modules/file_read.tr.en4
-rw-r--r--core/modules/file_read.tr.es4
-rw-r--r--core/modules/fs.sh91
-rw-r--r--core/modules/fs.tr.en4
-rw-r--r--core/modules/fs.tr.es4
-rw-r--r--core/modules/git.sh88
-rw-r--r--core/modules/git.tr.en7
-rw-r--r--core/modules/git.tr.es7
-rw-r--r--core/modules/groups.sh94
-rw-r--r--core/modules/groups.tr.en15
-rw-r--r--core/modules/groups.tr.es15
-rw-r--r--core/modules/lineinfile.sh91
-rw-r--r--core/modules/lineinfile.tr.en9
-rw-r--r--core/modules/lineinfile.tr.es9
-rw-r--r--core/modules/lookup.sh51
-rw-r--r--core/modules/lookup.tr.en3
-rw-r--r--core/modules/lookup.tr.es3
-rw-r--r--core/modules/loop.sh130
-rw-r--r--core/modules/loop.tr.en7
-rw-r--r--core/modules/loop.tr.es7
-rw-r--r--core/modules/openssl.sh141
-rw-r--r--core/modules/openssl.tr.en20
-rw-r--r--core/modules/openssl.tr.es20
-rw-r--r--core/modules/package.sh132
-rw-r--r--core/modules/package.tr.en10
-rw-r--r--core/modules/package.tr.es10
-rw-r--r--core/modules/ping.sh55
-rw-r--r--core/modules/ping.tr.en5
-rw-r--r--core/modules/ping.tr.es5
-rw-r--r--core/modules/replace.sh67
-rw-r--r--core/modules/replace.tr.en5
-rw-r--r--core/modules/replace.tr.es5
-rw-r--r--core/modules/run.sh90
-rw-r--r--core/modules/run.tr.en4
-rw-r--r--core/modules/run.tr.es4
-rw-r--r--core/modules/service.sh64
-rw-r--r--core/modules/service.tr.en6
-rw-r--r--core/modules/service.tr.es6
-rw-r--r--core/modules/smtp_send.sh94
-rw-r--r--core/modules/smtp_send.tr.en8
-rw-r--r--core/modules/smtp_send.tr.es8
-rw-r--r--core/modules/template.sh131
-rw-r--r--core/modules/template.tr.en5
-rw-r--r--core/modules/template.tr.es5
-rw-r--r--core/modules/user.tr.en15
-rw-r--r--core/modules/user.tr.es15
-rw-r--r--core/modules/users.sh106
-rw-r--r--core/modules/vault-remote.sh81
-rw-r--r--core/modules/vault-remote.tr.en8
-rw-r--r--core/modules/vault-remote.tr.es8
-rw-r--r--core/modules/wait.sh39
-rw-r--r--core/modules/wait.tr.en5
-rw-r--r--core/modules/wait.tr.es5
-rw-r--r--core/templates/.gitignore0
-rwxr-xr-xcore/utils/eg.sh27
-rwxr-xr-xcore/utils/module-docgen.sh69
-rw-r--r--core/utils/module-docgen.tr.en10
-rw-r--r--core/utils/module-docgen.tr.es10
-rwxr-xr-xcore/utils/module-template.sh76
-rw-r--r--core/utils/module-template.tr.en3
-rw-r--r--core/utils/module-template.tr.es3
-rwxr-xr-xcore/utils/shflow-check.sh100
-rw-r--r--core/utils/shflow-check.tr.en12
-rw-r--r--core/utils/shflow-check.tr.es12
-rwxr-xr-xcore/utils/shflow-doc.sh63
-rw-r--r--core/utils/shflow-doc.tr.en9
-rw-r--r--core/utils/shflow-doc.tr.es9
-rwxr-xr-xcore/utils/shflow-ssh-init.sh74
-rw-r--r--core/utils/shflow-ssh-init.tr.en12
-rw-r--r--core/utils/shflow-ssh-init.tr.es12
-rwxr-xr-xcore/utils/shflow-trust.sh90
-rw-r--r--core/utils/shflow-trust.tr.en14
-rw-r--r--core/utils/shflow-trust.tr.es14
-rwxr-xr-xcore/utils/vault-init.sh97
-rw-r--r--core/utils/vault-init.tr.en19
-rw-r--r--core/utils/vault-init.tr.es19
-rwxr-xr-xcore/utils/vault_utils.sh41
-rw-r--r--core/utils/vault_utils.tr.en2
-rw-r--r--core/utils/vault_utils.tr.es2
-rw-r--r--core/vault/.gitignore0
-rw-r--r--examples/api.yaml9
-rw-r--r--examples/api_body_test.yaml62
-rw-r--r--examples/api_get-json.yaml7
-rw-r--r--examples/api_post-json.yaml8
-rw-r--r--examples/api_post.yaml10
-rw-r--r--examples/api_reqres.yaml11
-rw-r--r--examples/api_soap-xml.yaml14
-rw-r--r--examples/apt-update.yaml13
-rw-r--r--examples/archive-compress-tar.gz.yaml9
-rw-r--r--examples/archive-decompress-zip.yaml8
-rw-r--r--examples/archive-extract-bootlog.yaml11
-rw-r--r--examples/archive-extract-tar.gz.yaml9
-rw-r--r--examples/basic_conditions.yaml15
-rw-r--r--examples/block-in-file.yaml10
-rw-r--r--examples/clone-and-configure.yaml24
-rw-r--r--examples/cron_root.yaml46
-rw-r--r--examples/deploy-nginx.yaml24
-rw-r--r--examples/docker_and_ldapsearch.yaml31
-rw-r--r--examples/docker_build-shflow.yaml8
-rw-r--r--examples/docker_hello-world.yaml8
-rw-r--r--examples/download.yaml9
-rw-r--r--examples/echo_capture.yaml32
-rw-r--r--examples/euvd_check.yaml17
-rw-r--r--examples/facts_key-value.yaml6
-rw-r--r--examples/facts_markdown.yaml8
-rw-r--r--examples/file_read_apache-config.yaml10
-rw-r--r--examples/fs-copy.yaml6
-rw-r--r--examples/fs-delete-multiple.yaml5
-rw-r--r--examples/fs-delete.yaml5
-rw-r--r--examples/fs-move-multiple.yaml6
-rw-r--r--examples/fs-move.yaml6
-rw-r--r--examples/fs-rename.yaml6
-rw-r--r--examples/fs-truncate-multiple.yaml5
-rw-r--r--examples/fs-truncate.yaml5
-rw-r--r--examples/full-stack.yaml0
-rw-r--r--examples/groups_minimal.yaml6
-rw-r--r--examples/install-tools.yaml14
-rw-r--r--examples/ldap_search.yaml24
-rw-r--r--examples/line-in-file.yaml8
-rw-r--r--examples/log-shflow.yaml15
-rw-r--r--examples/lookup_password.yaml18
-rw-r--r--examples/loop.yaml9
-rw-r--r--examples/loop_cartesian-values.yaml9
-rw-r--r--examples/loop_fail-fast.yaml10
-rw-r--r--examples/loop_key-value.yaml10
-rw-r--r--examples/loop_matrix.yaml14
-rw-r--r--examples/ls.yaml13
-rw-r--r--examples/minimal.yaml15
-rw-r--r--examples/openssl_autogen.yaml46
-rw-r--r--examples/ping.yaml7
-rw-r--r--examples/remote-ping.yaml8
-rw-r--r--examples/remote_user.yaml9
-rw-r--r--examples/replace-string.yaml8
-rw-r--r--examples/smtp_send_test.yaml12
-rw-r--r--examples/template.yaml8
-rw-r--r--examples/update_datetime.yaml43
-rw-r--r--examples/update_so.yaml10
-rw-r--r--examples/update_so_security.yaml8
-rw-r--r--examples/user_create.yaml12
-rw-r--r--examples/user_delete.yaml12
-rw-r--r--examples/user_modify.yaml12
-rw-r--r--examples/user_test.yaml32
-rw-r--r--examples/useradd.yaml8
-rw-r--r--examples/vault-demo.yaml7
-rw-r--r--examples/vault-sync.yaml23
-rw-r--r--examples/wait.yaml5
-rw-r--r--examples/winremote_check_test.yaml11
-rw-r--r--examples/winremote_detect.yaml8
-rw-r--r--examples/winremote_exec.yaml10
-rw-r--r--examples/winremote_exec_winrm.yml9
-rwxr-xr-xinstall.sh129
-rw-r--r--shflow-logo.ascii18
-rw-r--r--shflow-logo.pngbin0 -> 126318 bytes
-rwxr-xr-xshflow.sh308
-rw-r--r--shflow.tr.en32
-rw-r--r--shflow.tr.es32
-rw-r--r--user_modules/.gitignore0
-rwxr-xr-xvault.sh146
-rw-r--r--vault.tr.en13
-rw-r--r--vault.tr.es13
217 files changed, 6649 insertions, 0 deletions
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. <https://fsf.org/>
+ 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.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ 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 <https://www.gnu.org/licenses/>.
+
+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:
+
+ <program> Copyright (C) <year> <name of author>
+ 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
+<https://www.gnu.org/licenses/>.
+
+ 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
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/community_modules/ldap/ldap_ad.sh b/community_modules/ldap/ldap_ad.sh
new file mode 100644
index 0000000..361b7fb
--- /dev/null
+++ b/community_modules/ldap/ldap_ad.sh
@@ -0,0 +1,71 @@
+#!/bin/bash
+# Module: ldap_ad
+# Description: Realiza búsquedas filtradas en servidores Active Directory usando ldapsearch
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.1.0
+# Dependencies: ldapsearch
+
+ldap_ad_task() {
+ local host="$1"
+ shift
+
+ check_dependencies_ldap_ad || return 1
+
+ local state="" server="" port="389" base_dn="" filter="" attributes="" bind_dn="" password=""
+ for arg in "$@"; do
+ case "$arg" in
+ state=*) state="${arg#state=}" ;;
+ server=*) server="${arg#server=}" ;;
+ port=*) port="${arg#port=}" ;;
+ base_dn=*) base_dn="${arg#base_dn=}" ;;
+ filter=*) filter="${arg#filter=}" ;;
+ attributes=*) attributes="${arg#attributes=}" ;;
+ bind_dn=*) bind_dn="${arg#bind_dn=}" ;;
+ password=*) password="${arg#password=}" ;;
+ esac
+ done
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/ldap_ad.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if [[ "$state" != "search" ]]; then
+ echo "$(render_msg "${tr[unsupported_state]}" "state=$state")"
+ return 1
+ fi
+
+ if [[ -z "$server" || -z "$base_dn" || -z "$filter" ]]; then
+ echo "${tr[missing_args]:-❌ [ldap_ad] Faltan argumentos obligatorios: server, base_dn, filter}"
+ return 1
+ fi
+
+ echo "$(render_msg "${tr[connecting]}" "server=$server" "port=$port")"
+ local cmd=(ldapsearch -LLL -H "$server" -p "$port" -b "$base_dn" "$filter")
+ [[ -n "$bind_dn" && -n "$password" ]] && cmd=(-D "$bind_dn" -w "$password" "${cmd[@]}")
+ [[ -n "$attributes" ]] && IFS=',' read -ra attr_list <<< "$attributes" && cmd+=("${attr_list[@]}")
+
+ if "${cmd[@]}" 2>/tmp/ldap_ad_error.log | grep -E '^(dn:|cn:|mail:|sAMAccountName:)' ; then
+ echo "${tr[success]:-✅ [ldap_ad] Búsqueda completada con éxito}"
+ else
+ echo "${tr[no_results]:-⚠️ [ldap_ad] No se encontraron resultados o hubo un error}"
+ cat /tmp/ldap_ad_error.log
+ return 1
+ fi
+}
+
+check_dependencies_ldap_ad() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/ldap_ad.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if ! command -v ldapsearch &>/dev/null; then
+ echo "${tr[missing_dep]:-❌ [ldap_ad] El comando 'ldapsearch' no está disponible}"
+ return 1
+ fi
+ echo "${tr[deps_ok]:-✅ [ldap_ad] Dependencias OK}"
+ return 0
+}
diff --git a/community_modules/ldap/ldap_ad.tr.en b/community_modules/ldap/ldap_ad.tr.en
new file mode 100644
index 0000000..55214ae
--- /dev/null
+++ b/community_modules/ldap/ldap_ad.tr.en
@@ -0,0 +1,7 @@
+unsupported_state=❌ [ldap_ad] Unsupported state: '{state}'. Only 'search' is allowed
+missing_args=❌ [ldap_ad] Missing required arguments: server, base_dn, filter
+connecting=🔍 [ldap_ad] Connecting to {server}:{port}...
+success=✅ [ldap_ad] Search completed successfully
+no_results=⚠️ [ldap_ad] No results found or an error occurred
+missing_dep=❌ [ldap_ad] 'ldapsearch' command is not available
+deps_ok=✅ [ldap_ad] Dependencies OK
diff --git a/community_modules/ldap/ldap_ad.tr.es b/community_modules/ldap/ldap_ad.tr.es
new file mode 100644
index 0000000..93dca53
--- /dev/null
+++ b/community_modules/ldap/ldap_ad.tr.es
@@ -0,0 +1,7 @@
+unsupported_state=❌ [ldap_ad] Estado no soportado: '{state}'. Solo se permite 'search'
+missing_args=❌ [ldap_ad] Faltan argumentos obligatorios: server, base_dn, filter
+connecting=🔍 [ldap_ad] Conectando a {server}:{port}...
+success=✅ [ldap_ad] Búsqueda completada con éxito
+no_results=⚠️ [ldap_ad] No se encontraron resultados o hubo un error
+missing_dep=❌ [ldap_ad] El comando 'ldapsearch' no está disponible
+deps_ok=✅ [ldap_ad] Dependencias OK
diff --git a/community_modules/ldap/ldap_openldap.sh b/community_modules/ldap/ldap_openldap.sh
new file mode 100644
index 0000000..bfe8a85
--- /dev/null
+++ b/community_modules/ldap/ldap_openldap.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+# Module: ldap_openldap
+# Description: Realiza búsquedas filtradas en servidores OpenLDAP usando ldapsearch
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.1.0
+# Dependencies: ldapsearch
+
+ldap_openldap_task() {
+ local host="$1"
+ shift
+
+ check_dependencies_ldap_openldap || return 1
+
+ local state="" server="" port="389" base_dn="" filter="" attributes="" bind_dn="" password=""
+ for arg in "$@"; do
+ case "$arg" in
+ state=*) state="${arg#state=}" ;;
+ server=*) server="${arg#server=}" ;;
+ port=*) port="${arg#port=}" ;;
+ base_dn=*) base_dn="${arg#base_dn=}" ;;
+ filter=*) filter="${arg#filter=}" ;;
+ attributes=*) attributes="${arg#attributes=}" ;;
+ bind_dn=*) bind_dn="${arg#bind_dn=}" ;;
+ password=*) password="${arg#password=}" ;;
+ esac
+ done
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/ldap_openldap.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if [[ "$state" != "search" ]]; then
+ echo "$(render_msg "${tr[unsupported_state]}" "state=$state")"
+ return 1
+ fi
+
+ if [[ -z "$server" || -z "$base_dn" || -z "$filter" ]]; then
+ echo "${tr[missing_args]:-❌ [ldap_openldap] Faltan argumentos obligatorios: server, base_dn, filter}"
+ return 1
+ fi
+
+ echo "$(render_msg "${tr[connecting]}" "server=$server" "port=$port")"
+ local cmd=(ldapsearch -x -H "$server:$port")
+ [[ -n "$bind_dn" && -n "$password" ]] && cmd+=(-D "$bind_dn" -w "$password")
+ cmd+=(-b "$base_dn" "$filter")
+ [[ -n "$attributes" ]] && IFS=',' read -ra attr_list <<< "$attributes" && cmd+=("${attr_list[@]}")
+
+ if "${cmd[@]}" 2>/tmp/ldap_error.log | grep -E '^(dn:|cn:|mail:|uid:)' ; then
+ echo "${tr[success]:-✅ [ldap_openldap] Búsqueda completada con éxito}"
+ else
+ echo "${tr[no_results]:-⚠️ [ldap_openldap] No se encontraron resultados o hubo un error}"
+ cat /tmp/ldap_error.log
+ return 1
+ fi
+}
+
+check_dependencies_ldap_openldap() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/ldap_openldap.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if ! command -v ldapsearch &>/dev/null; then
+ echo "${tr[missing_dep]:-❌ [ldap_openldap] El comando 'ldapsearch' no está disponible}"
+ return 1
+ fi
+ echo "${tr[deps_ok]:-✅ [ldap_openldap] Dependencias OK}"
+ return 0
+}
diff --git a/community_modules/ldap/ldap_openldap.tr.en b/community_modules/ldap/ldap_openldap.tr.en
new file mode 100644
index 0000000..9868fb5
--- /dev/null
+++ b/community_modules/ldap/ldap_openldap.tr.en
@@ -0,0 +1,7 @@
+unsupported_state=❌ [ldap_openldap] Unsupported state: '{state}'. Only 'search' is allowed
+missing_args=❌ [ldap_openldap] Missing required arguments: server, base_dn, filter
+connecting=🔍 [ldap_openldap] Connecting to {server}:{port}...
+success=✅ [ldap_openldap] Search completed successfully
+no_results=⚠️ [ldap_openldap] No results found or an error occurred
+missing_dep=❌ [ldap_openldap] 'ldapsearch' command is not available
+deps_ok=✅ [ldap_openldap] Dependencies OK
diff --git a/community_modules/ldap/ldap_openldap.tr.es b/community_modules/ldap/ldap_openldap.tr.es
new file mode 100644
index 0000000..62a649b
--- /dev/null
+++ b/community_modules/ldap/ldap_openldap.tr.es
@@ -0,0 +1,7 @@
+unsupported_state=❌ [ldap_openldap] Estado no soportado: '{state}'. Solo se permite 'search'
+missing_args=❌ [ldap_openldap] Faltan argumentos obligatorios: server, base_dn, filter
+connecting=🔍 [ldap_openldap] Conectando a {server}:{port}...
+success=✅ [ldap_openldap] Búsqueda completada con éxito
+no_results=⚠️ [ldap_openldap] No se encontraron resultados o hubo un error
+missing_dep=❌ [ldap_openldap] El comando 'ldapsearch' no está disponible
+deps_ok=✅ [ldap_openldap] Dependencias OK
diff --git a/community_modules/security/euvd_check.sh b/community_modules/security/euvd_check.sh
new file mode 100644
index 0000000..2205a42
--- /dev/null
+++ b/community_modules/security/euvd_check.sh
@@ -0,0 +1,101 @@
+#!/bin/bash
+# Module: euvd_check
+# Description: Verifica si un host remoto está afectado por una vulnerabilidad EUVD consultando la base europea ENISA
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 0.6.0
+# Dependencies: curl, jq, ssh, dpkg o rpm
+
+euvd_check_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local enisa_id="${args[enisa_id]}"
+ local package="${args[package]}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/euvd_check.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if [[ -z "$enisa_id" || -z "$package" ]]; then
+ echo "${tr[missing_args]:-❌ [euvd_check] Faltan argumentos: enisa_id y package son obligatorios.}"
+ return 1
+ fi
+
+ echolog 1 "$(render_msg "${tr[start]}" "id=$enisa_id" "package=$package" "host=$host")"
+
+ local pkg_cmd=""
+ if ssh "$host" "command -v dpkg" &>/dev/null; then
+ pkg_cmd="dpkg -s"
+ echolog 1 "${tr[detected_dpkg]:-🔧 Gestor de paquetes detectado: dpkg}"
+ elif ssh "$host" "command -v rpm" &>/dev/null; then
+ pkg_cmd="rpm -q"
+ echolog 1 "${tr[detected_rpm]:-🔧 Gestor de paquetes detectado: rpm}"
+ else
+ echo "${tr[no_pkg]:-❌ [euvd_check] No se detectó gestor de paquetes compatible en el host}"
+ return 1
+ fi
+
+ local version_cmd="$pkg_cmd $package"
+ [[ "$become" = "true" ]] && version_cmd="sudo $version_cmd"
+
+ local version
+ version=$(ssh "$host" "$version_cmd" 2>/dev/null | grep -E 'Version|version|^'"$package" | head -n1 | awk '{print $2}')
+
+ if [[ -z "$version" ]]; then
+ echolog 1 "$(render_msg "${tr[version_fail]}" "package=$package" "host=$host")"
+ return 1
+ fi
+
+ echolog 1 "$(render_msg "${tr[version_ok]}" "version=$version")"
+
+ local enisa_url="https://euvdservices.enisa.europa.eu/api/enisaid?id=$enisa_id"
+ echolog 1 "$(render_msg "${tr[query_enisa]}" "id=$enisa_id")"
+ local response
+ response=$(curl -s -X GET "$enisa_url")
+
+ if ! echo "$response" | jq -e .description &>/dev/null; then
+ echolog 1 "$(render_msg "${tr[invalid_response]}" "id=$enisa_id")"
+ echolog 1 "$(render_msg "${tr[response_trunc]}" "snippet=$(echo "$response" | head -c 120 | tr '\n' ' ')")"
+ return 1
+ fi
+
+ local score desc aliases
+ score=$(echo "$response" | jq -r '.baseScore // empty')
+ desc=$(echo "$response" | jq -r '.description // empty')
+ aliases=$(echo "$response" | jq -r '.aliases[]?')
+
+ [[ -n "$score" ]] && echolog 1 "$(render_msg "${tr[score]}" "score=$score")"
+ echolog 2 "$(render_msg "${tr[desc]}" "desc=$desc")"
+ [[ -n "$aliases" ]] && echolog 1 "$(render_msg "${tr[aliases]}" "aliases=$aliases")"
+
+ if echo "$desc" | grep -iq "$package" && echo "$desc" | grep -q "$version"; then
+ echo "$(render_msg "${tr[vulnerable]}" "host=$host" "id=$enisa_id")"
+ return 1
+ else
+ echo "$(render_msg "${tr[safe]}" "host=$host" "id=$enisa_id")"
+ return 0
+ fi
+}
+
+check_dependencies_euvd_check() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/euvd_check.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ for cmd in ssh curl jq; do
+ if ! command -v "$cmd" &> /dev/null; then
+ echo "$(render_msg "${tr[missing_dep]}" "cmd=$cmd")"
+ return 1
+ fi
+ done
+ echo "${tr[deps_ok]:-✅ [euvd_check] ssh, curl y jq disponibles.}"
+ return 0
+}
diff --git a/community_modules/security/euvd_check.tr.en b/community_modules/security/euvd_check.tr.en
new file mode 100644
index 0000000..ae95dd0
--- /dev/null
+++ b/community_modules/security/euvd_check.tr.en
@@ -0,0 +1,17 @@
+missing_args=❌ [euvd_check] Missing arguments: enisa_id and package are required.
+start=🧬 [euvd_check] Checking {id} in package '{package}' on {host}...
+detected_dpkg=🔧 Package manager detected: dpkg
+detected_rpm=🔧 Package manager detected: rpm
+no_pkg=❌ [euvd_check] No compatible package manager detected on host
+version_fail=⚠️ [euvd_check] Could not detect installed version of '{package}' on {host}.
+version_ok=🔍 Installed version: {version}
+query_enisa=🌐 Querying ENISA for {id}...
+invalid_response=⚠️ [euvd_check] ENISA response does not contain valid data for {id}.
+response_trunc=🔍 Response (truncated): {snippet}
+score=📊 CVSS Score: {score}
+desc=📝 Description: {desc}
+aliases=🔗 Aliases: {aliases}
+vulnerable=❌ [euvd_check] Host {host} is vulnerable to {id}
+safe=✅ [euvd_check] Host {host} does not appear affected by {id}
+missing_dep=❌ [euvd_check] Command '{cmd}' is not available
+deps_ok=✅ [euvd_check] ssh, curl and jq are available.
diff --git a/community_modules/security/euvd_check.tr.es b/community_modules/security/euvd_check.tr.es
new file mode 100644
index 0000000..85e0315
--- /dev/null
+++ b/community_modules/security/euvd_check.tr.es
@@ -0,0 +1,17 @@
+missing_args=❌ [euvd_check] Faltan argumentos: enisa_id y package son obligatorios.
+start=🧬 [euvd_check] Verificando {id} en paquete '{package}' en {host}...
+detected_dpkg=🔧 Gestor de paquetes detectado: dpkg
+detected_rpm=🔧 Gestor de paquetes detectado: rpm
+no_pkg=❌ [euvd_check] No se detectó gestor de paquetes compatible en el host
+version_fail=⚠️ [euvd_check] No se pudo detectar la versión instalada de '{package}' en {host}.
+version_ok=🔍 Versión instalada: {version}
+query_enisa=🌐 Consultando ENISA para {id}...
+invalid_response=⚠️ [euvd_check] La respuesta de ENISA no contiene datos válidos para {id}.
+response_trunc=🔍 Respuesta (truncada): {snippet}
+score=📊 Puntuación CVSS: {score}
+desc=📝 Descripción: {desc}
+aliases=🔗 Alias: {aliases}
+vulnerable=❌ [euvd_check] Host {host} está vulnerable a {id}
+safe=✅ [euvd_check] Host {host} no parece afectado por {id}
+missing_dep=❌ [euvd_check] El comando '{cmd}' no está disponible
+deps_ok=✅ [euvd_check] ssh, curl y jq disponibles.
diff --git a/community_modules/winremote/winremote_check.sh b/community_modules/winremote/winremote_check.sh
new file mode 100644
index 0000000..4f0d608
--- /dev/null
+++ b/community_modules/winremote/winremote_check.sh
@@ -0,0 +1,55 @@
+#!/bin/bash
+# Module: winremote_check
+# Description: Verifica conectividad y ejecución remota básica en equipos Windows mediante SSH y PowerShell
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.2.0
+# Dependencies: ssh, powershell (en el host remoto)
+
+winremote_check_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local winuser="${args[winuser]}"
+ local winpassword="${args[winpassword]}"
+ local port="${args[port]:-22}"
+ local command="Write-Output 'Conexión establecida desde ShFlow'"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/winremote_check.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if [[ -z "$host" || -z "$winuser" || -z "$winpassword" ]]; then
+ echo "${tr[missing_args]:-❌ [winremote_check] Parámetros incompletos. Se requiere host, winuser y winpassword.}"
+ return 1
+ fi
+
+ [[ "$host" == *@* ]] && host=$(echo "$host" | awk -F '@' '{print $2}')
+
+ echo "$(render_msg "${tr[start]}" "host=$host")"
+
+ if sshpass -p "$winpassword" ssh -o PreferredAuthentications=password -o StrictHostKeyChecking=no -p "$port" "$winuser@$host" powershell -Command "\"$command\"" &>/dev/null; then
+ echo "$(render_msg "${tr[success]}" "host=$host")"
+ return 0
+ else
+ echo "$(render_msg "${tr[fail]}" "host=$host")"
+ return 1
+ fi
+}
+
+check_dependencies_winremote_check() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/winremote_check.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if ! command -v ssh &> /dev/null; then
+ echo "${tr[missing_ssh]:-❌ [winremote_check] ssh no está disponible.}"
+ return 1
+ fi
+ echo "${tr[ssh_ok]:-✅ [winremote_check] ssh disponible.}"
+ return 0
+}
diff --git a/community_modules/winremote/winremote_check.tr.en b/community_modules/winremote/winremote_check.tr.en
new file mode 100644
index 0000000..3af94a8
--- /dev/null
+++ b/community_modules/winremote/winremote_check.tr.en
@@ -0,0 +1,6 @@
+missing_args=❌ [winremote_check] Missing parameters. host, winuser and winpassword are required.
+start=🖥️ [winremote_check] Checking remote access to {host}...
+success=✅ [winremote_check] Remote connection and execution OK on {host}
+fail=❌ [winremote_check] Connection or execution failed on {host}
+missing_ssh=❌ [winremote_check] ssh is not available.
+ssh_ok=✅ [winremote_check] ssh is available.
diff --git a/community_modules/winremote/winremote_check.tr.es b/community_modules/winremote/winremote_check.tr.es
new file mode 100644
index 0000000..b80f33b
--- /dev/null
+++ b/community_modules/winremote/winremote_check.tr.es
@@ -0,0 +1,6 @@
+missing_args=❌ [winremote_check] Parámetros incompletos. Se requiere host, winuser y winpassword.
+start=🖥️ [winremote_check] Verificando acceso remoto a {host}...
+success=✅ [winremote_check] Conexión y ejecución remota OK en {host}
+fail=❌ [winremote_check] Fallo de conexión o ejecución en {host}
+missing_ssh=❌ [winremote_check] ssh no está disponible.
+ssh_ok=✅ [winremote_check] ssh disponible.
diff --git a/community_modules/winremote/winremote_detect.sh b/community_modules/winremote/winremote_detect.sh
new file mode 100644
index 0000000..4477acd
--- /dev/null
+++ b/community_modules/winremote/winremote_detect.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+# Module: winremote_detect
+# Description: Detecta si un host Windows tiene habilitado SSH, WinRM, ambos o ninguno
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.2.0
+# Dependencies: nc, curl, pwsh (opcional)
+
+winremote_detect_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local ssh_port="${args[ssh_port]:-22}"
+ local winrm_port="${args[winrm_port]:-5985}"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/winremote_detect.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ [[ "$host" == *@* ]] && host=$(echo "$host" | awk -F '@' '{print $2}')
+
+ echo "$(render_msg "${tr[start]}" "host=$host")"
+
+ local ssh_status="${tr[ssh_off]:-❌ SSH no disponible}"
+ local winrm_status="${tr[winrm_off]:-❌ WinRM no disponible}"
+
+ if nc -z -w2 "$host" "$ssh_port" &>/dev/null; then
+ ssh_status="$(render_msg "${tr[ssh_on]}" "port=$ssh_port")"
+ fi
+
+ if curl -s -o /dev/null -w "%{http_code}" --connect-timeout 2 "http://$host:$winrm_port/wsman" | grep -q "405"; then
+ winrm_status="$(render_msg "${tr[winrm_on]}" "port=$winrm_port")"
+ fi
+
+ echo " $ssh_status"
+ echo " $winrm_status"
+
+ if [[ "$ssh_status" == *✅* && "$winrm_status" == *✅* ]]; then
+ echo "$(render_msg "${tr[both]}" "host=$host")"
+ return 0
+ elif [[ "$ssh_status" == *✅* || "$winrm_status" == *✅* ]]; then
+ echo "${tr[one]:-🟡 Uno de los protocolos está disponible}"
+ return 0
+ else
+ echo "$(render_msg "${tr[none]}" "host=$host")"
+ return 1
+ fi
+}
+
+check_dependencies_winremote_detect() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/winremote_detect.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if ! command -v nc &> /dev/null || ! command -v curl &> /dev/null; then
+ echo "${tr[missing_deps]:-❌ [winremote_detect] nc o curl no están disponibles.}"
+ return 1
+ fi
+ echo "${tr[deps_ok]:-✅ [winremote_detect] nc y curl disponibles.}"
+ return 0
+}
diff --git a/community_modules/winremote/winremote_detect.tr.en b/community_modules/winremote/winremote_detect.tr.en
new file mode 100644
index 0000000..986e44f
--- /dev/null
+++ b/community_modules/winremote/winremote_detect.tr.en
@@ -0,0 +1,10 @@
+start=🔍 [winremote_detect] Checking connectivity with {host}...
+ssh_on=✅ SSH enabled (port {port})
+ssh_off=❌ SSH not available
+winrm_on=✅ WinRM enabled (port {port})
+winrm_off=❌ WinRM not available
+both=🟢 Both protocols available on {host}
+one=🟡 One protocol is available
+none=🔴 No remote protocol detected on {host}
+missing_deps=❌ [winremote_detect] nc or curl are not available.
+deps_ok=✅ [winremote_detect] nc and curl are available.
diff --git a/community_modules/winremote/winremote_detect.tr.es b/community_modules/winremote/winremote_detect.tr.es
new file mode 100644
index 0000000..530a034
--- /dev/null
+++ b/community_modules/winremote/winremote_detect.tr.es
@@ -0,0 +1,10 @@
+start=🔍 [winremote_detect] Analizando conectividad con {host}...
+ssh_on=✅ SSH habilitado (puerto {port})
+ssh_off=❌ SSH no disponible
+winrm_on=✅ WinRM habilitado (puerto {port})
+winrm_off=❌ WinRM no disponible
+both=🟢 Ambos protocolos disponibles en {host}
+one=🟡 Uno de los protocolos está disponible
+none=🔴 Ningún protocolo remoto detectado en {host}
+missing_deps=❌ [winremote_detect] nc o curl no están disponibles.
+deps_ok=✅ [winremote_detect] nc y curl disponibles.
diff --git a/community_modules/winremote/winremote_exec.sh b/community_modules/winremote/winremote_exec.sh
new file mode 100644
index 0000000..79bf413
--- /dev/null
+++ b/community_modules/winremote/winremote_exec.sh
@@ -0,0 +1,60 @@
+#!/bin/bash
+# Module: winremote_exec
+# Description: Ejecuta comandos PowerShell en un host Windows remoto vía SSH
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.2.0
+# Dependencies: sshpass, ssh
+
+winremote_exec_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local winuser="${args[winuser]}"
+ local winpassword="${args[winpassword]}"
+ local port="${args[port]:-22}"
+ local command="${args[command]}"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/winremote_exec.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if [[ -z "$host" || -z "$winuser" || -z "$winpassword" || -z "$command" ]]; then
+ echo "${tr[missing_args]:-❌ [winremote_exec] Parámetros incompletos. Se requiere host, winuser, winpassword y command.}"
+ return 1
+ fi
+
+ [[ "$host" == *@* ]] && host=$(echo "$host" | awk -F '@' '{print $2}')
+ local safe_command=$(printf "%q" "$command")
+
+ echo "$(render_msg "${tr[start]}" "host=$host" "port=$port" "user=$winuser")"
+
+ sshpass -p "$winpassword" ssh -o PreferredAuthentications=password -o StrictHostKeyChecking=no -p "$port" "$winuser@$host" \
+ "powershell -Command \"$safe_command\""
+
+ local exit_code=$?
+ if [[ $exit_code -eq 0 ]]; then
+ echo "${tr[success]:-✅ [winremote_exec] Comando ejecutado correctamente.}"
+ return 0
+ else
+ echo "$(render_msg "${tr[fail]}" "code=$exit_code")"
+ return $exit_code
+ fi
+}
+
+check_dependencies_winremote_exec() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/winremote_exec.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if ! command -v sshpass &> /dev/null || ! command -v ssh &> /dev/null; then
+ echo "${tr[missing_deps]:-❌ [winremote_exec] sshpass o ssh no están disponibles.}"
+ return 1
+ fi
+ echo "${tr[deps_ok]:-✅ [winremote_exec] sshpass y ssh disponibles.}"
+ return 0
+}
diff --git a/community_modules/winremote/winremote_exec.tr.en b/community_modules/winremote/winremote_exec.tr.en
new file mode 100644
index 0000000..e1fa189
--- /dev/null
+++ b/community_modules/winremote/winremote_exec.tr.en
@@ -0,0 +1,6 @@
+missing_args=❌ [winremote_exec] Missing parameters. host, winuser, winpassword and command are required.
+start=🔧 [winremote_exec] Executing remote command on {host}:{port} as {user}...
+success=✅ [winremote_exec] Command executed successfully.
+fail=❌ [winremote_exec] Error executing command (code {code}).
+missing_deps=❌ [winremote_exec] sshpass or ssh are not available.
+deps_ok=✅ [winremote_exec] sshpass and ssh are available.
diff --git a/community_modules/winremote/winremote_exec.tr.es b/community_modules/winremote/winremote_exec.tr.es
new file mode 100644
index 0000000..1a52f10
--- /dev/null
+++ b/community_modules/winremote/winremote_exec.tr.es
@@ -0,0 +1,6 @@
+missing_args=❌ [winremote_exec] Parámetros incompletos. Se requiere host, winuser, winpassword y command.
+start=🔧 [winremote_exec] Ejecutando comando remoto en {host}:{port} como {user}...
+success=✅ [winremote_exec] Comando ejecutado correctamente.
+fail=❌ [winremote_exec] Error al ejecutar el comando (código {code}).
+missing_deps=❌ [winremote_exec] sshpass o ssh no están disponibles.
+deps_ok=✅ [winremote_exec] sshpass y ssh disponibles.
diff --git a/community_modules/winremote/winremote_exec_winrm.sh b/community_modules/winremote/winremote_exec_winrm.sh
new file mode 100644
index 0000000..8d4e1bd
--- /dev/null
+++ b/community_modules/winremote/winremote_exec_winrm.sh
@@ -0,0 +1,74 @@
+#!/bin/bash
+# Module: winremote_exec_winrm
+# Description: Ejecuta comandos en un host Windows remoto vía WSMan (WinRM) desde Linux
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.3.0
+# Dependencies: wsman
+
+winremote_exec_winrm_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local winuser="${args[winuser]}"
+ local winpassword="${args[winpassword]}"
+ local port="${args[port]:-5985}"
+ local command="${args[command]}"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/winremote_exec_winrm.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if [[ -z "$host" || -z "$winuser" || -z "$winpassword" || -z "$command" ]]; then
+ echo "${tr[missing_args]:-❌ [winremote_exec_winrm] Parámetros incompletos. Se requiere host, winuser, winpassword y command.}"
+ return 1
+ fi
+
+ [[ "$host" == *@* ]] && host=$(echo "$host" | awk -F '@' '{print $2}')
+
+ local xml_file=$(mktemp --suffix=.xml)
+ trap '[[ -n "$xml_file" && -f "$xml_file" ]] && rm -f "$xml_file"' EXIT
+
+ cat > "$xml_file" <<EOF
+<p:Create_INPUT xmlns:p="http://schemas.microsoft.com/wbem/wsman/1/wmi/root/cimv2/Win32_Process">
+ <p:CommandLine>${command}</p:CommandLine>
+</p:Create_INPUT>
+EOF
+
+ echo "$(render_msg "${tr[start]}" "host=$host" "port=$port" "user=$winuser")"
+
+ wsman invoke http://schemas.microsoft.com/wbem/wsman/1/wmi/root/cimv2/Win32_Process \
+ -a Create \
+ -h "$host" \
+ -P "$port" \
+ -u "$winuser" \
+ -p "$winpassword" \
+ -y basic \
+ -J "$xml_file"
+
+ local exit_code=$?
+ if [[ $exit_code -eq 0 ]]; then
+ echo "${tr[success]:-✅ [winremote_exec_winrm] Comando ejecutado correctamente vía WSMan.}"
+ return 0
+ else
+ echo "$(render_msg "${tr[fail]}" "code=$exit_code")"
+ return $exit_code
+ fi
+}
+
+check_dependencies_winremote_exec_winrm() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/winremote_exec_winrm.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if ! command -v wsman &> /dev/null; then
+ echo "${tr[missing_wsman]:-❌ [winremote_exec_winrm] El cliente 'wsman' no está disponible.}"
+ return 1
+ fi
+ echo "${tr[wsman_ok]:-✅ [winremote_exec_winrm] Cliente 'wsman' disponible.}"
+ return 0
+}
diff --git a/community_modules/winremote/winremote_exec_winrm.tr.en b/community_modules/winremote/winremote_exec_winrm.tr.en
new file mode 100644
index 0000000..ddb3ebe
--- /dev/null
+++ b/community_modules/winremote/winremote_exec_winrm.tr.en
@@ -0,0 +1,6 @@
+missing_args=❌ [winremote_exec_winrm] Missing parameters. host, winuser, winpassword and command are required.
+start=🔧 [winremote_exec_winrm] Executing remote command on {host}:{port} as {user}...
+success=✅ [winremote_exec_winrm] Command executed successfully via WSMan.
+fail=❌ [winremote_exec_winrm] Error executing command (code {code}).
+missing_wsman=❌ [winremote_exec_winrm] 'wsman' client is not available.
+wsman_ok=✅ [winremote_exec_winrm] 'wsman' client is available.
diff --git a/community_modules/winremote/winremote_exec_winrm.tr.es b/community_modules/winremote/winremote_exec_winrm.tr.es
new file mode 100644
index 0000000..51fe754
--- /dev/null
+++ b/community_modules/winremote/winremote_exec_winrm.tr.es
@@ -0,0 +1,6 @@
+missing_args=❌ [winremote_exec_winrm] Parámetros incompletos. Se requiere host, winuser, winpassword y command.
+start=🔧 [winremote_exec_winrm] Ejecutando comando remoto en {host}:{port} como {user}...
+success=✅ [winremote_exec_winrm] Comando ejecutado correctamente vía WSMan.
+fail=❌ [winremote_exec_winrm] Error al ejecutar el comando (código {code}).
+missing_wsman=❌ [winremote_exec_winrm] El cliente 'wsman' no está disponible.
+wsman_ok=✅ [winremote_exec_winrm] Cliente 'wsman' disponible.
diff --git a/core/inventory/groups.yaml b/core/inventory/groups.yaml
new file mode 100644
index 0000000..52dae9b
--- /dev/null
+++ b/core/inventory/groups.yaml
@@ -0,0 +1,9 @@
+# groups.yaml — Definición de grupos en ShFlow
+# Este archivo permite describir grupos, asignar etiquetas y metadatos.
+# Sintaxis:
+# groups:
+# nombre_grupo:
+# description: <texto descriptivo>
+# tags: [tag1, tag2, ...]
+
+groups: {}
diff --git a/core/inventory/hosts.yaml b/core/inventory/hosts.yaml
new file mode 100644
index 0000000..faafa09
--- /dev/null
+++ b/core/inventory/hosts.yaml
@@ -0,0 +1,17 @@
+# hosts.yaml — Inventario principal de ShFlow
+# Este archivo define los hosts y su pertenencia a grupos.
+# Sintaxis:
+# all:
+# hosts:
+# nombre_host:
+# ansible_host: <IP o FQDN>
+# become: <true|false>
+# <clave>: <valor>
+# children:
+# nombre_grupo:
+# hosts:
+# nombre_host:
+
+all:
+ hosts: {}
+ children: {}
diff --git a/core/inventory/vars/all.yaml b/core/inventory/vars/all.yaml
new file mode 100644
index 0000000..e0c5b8f
--- /dev/null
+++ b/core/inventory/vars/all.yaml
@@ -0,0 +1,20 @@
+# all.yaml — Variables globales para todos los hosts en ShFlow
+# Este archivo define valores comunes que se aplican a todos los hosts del inventario.
+# Sintaxis:
+# clave: valor
+# Las variables aquí definidas pueden ser sobrescritas por vars de grupo o de host.
+
+language: es
+timezone: Europe/Madrid
+ntp_servers:
+ - 0.europe.pool.ntp.org
+ - 1.europe.pool.ntp.org
+default_packages:
+ - curl
+ - vim
+ - bash-completion
+ssh_port: 22
+become: true
+env: production
+vault_enabled: true
+vault_rotation_interval: 30d
diff --git a/core/lib/translate_msg.sh b/core/lib/translate_msg.sh
new file mode 100644
index 0000000..3a1e4a9
--- /dev/null
+++ b/core/lib/translate_msg.sh
@@ -0,0 +1,9 @@
+render_msg() {
+ local template="$1"; shift
+ for pair in "$@"; do
+ local key="${pair%%=*}"
+ local val="${pair#*=}"
+ template="${template//\{$key\}/$val}"
+ done
+ echo "$template"
+}
diff --git a/core/modules/api.sh b/core/modules/api.sh
new file mode 100644
index 0000000..a58f81a
--- /dev/null
+++ b/core/modules/api.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# Module: api
+# Description: Cliente declarativo para APIs REST y SOAP (GET, POST, PUT, DELETE, SOAP)
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.1.0
+# Dependencies: curl, jq, xmllint
+
+api_task() {
+ local host="$1"; shift
+ declare -A args
+ local headers=()
+ local method="" body="" url="" output="" parse=""
+
+ for arg in "$@"; do
+ key="${arg%%=*}"; value="${arg#*=}"
+ case "$key" in
+ headers) IFS=',' read -r -a headers <<< "$value" ;;
+ body) body="$value" ;;
+ url) url="$value" ;;
+ method) method="${value,,}" ;;
+ output) output="$value" ;;
+ parse) parse="${value,,}" ;;
+ esac
+ done
+
+ [[ -z "$method" ]] && method="get"
+ [[ "$method" == "get" ]] && method="GET"
+ [[ "$method" == "post" ]] && method="POST"
+ [[ "$method" == "soap" ]] && method="POST"
+
+ local header_args=""
+ for h in "${headers[@]}"; do header_args+=" -H \"$h\""; done
+
+ local curl_cmd="curl -sSL -X $method \"$url\"$header_args"
+ [[ -n "$body" ]] && curl_cmd+=" --data-raw '$body'"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/api.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ echo "$(render_msg "${tr[start]}" "method=$method" "url=$url")"
+ [[ "$DEBUG" == "true" ]] && echo "$(render_msg "${tr[debug_cmd]}" "cmd=$curl_cmd")"
+ [[ "$DEBUG" == "true" && -n "$body" ]] && echo -e "$(render_msg "${tr[debug_body]}" "body=$body")"
+
+ local response
+ if [[ "$host" == "localhost" ]]; then
+ response=$(eval "$curl_cmd")
+ else
+ response=$(ssh "$host" "$curl_cmd")
+ fi
+
+ if [[ -n "$output" ]]; then
+ echo "$response" > "$output"
+ echo "$(render_msg "${tr[saved]}" "output=$output")"
+ fi
+
+ case "$parse" in
+ json)
+ echo "$response" | jq '.' 2>/dev/null || echo "${tr[json_fail]:-⚠️ [api] No se pudo parsear como JSON}"
+ ;;
+ xml)
+ echo "$response" | xmllint --format - 2>/dev/null || {
+ echo "${tr[xml_fail]:-⚠️ [api] No se pudo parsear como XML}"
+ echo "$response"
+ }
+ ;;
+ *) echo "$response" ;;
+ esac
+}
+
+check_dependencies_api() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/api.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ for cmd in curl jq xmllint; do
+ if ! command -v "$cmd" &> /dev/null; then
+ echo "$(render_msg "${tr[missing_cmd]}" "cmd=$cmd")"
+ else
+ echo "$(render_msg "${tr[cmd_ok]}" "cmd=$cmd")"
+ fi
+ done
+ return 0
+}
diff --git a/core/modules/api.tr.en b/core/modules/api.tr.en
new file mode 100644
index 0000000..fce15f6
--- /dev/null
+++ b/core/modules/api.tr.en
@@ -0,0 +1,8 @@
+start=🌐 [api] Executing {method} → {url}
+debug_cmd=🔍 Actual command: {cmd}
+debug_body=📦 [api] Body sent:\n{body}
+saved=💾 [api] Response saved to: {output}
+json_fail=⚠️ [api] Failed to parse as JSON
+xml_fail=⚠️ [api] Failed to parse as XML
+missing_cmd=⚠️ [api] '{cmd}' not available locally. Assuming it exists on the remote host.
+cmd_ok=✅ [api] '{cmd}' available locally.
diff --git a/core/modules/api.tr.es b/core/modules/api.tr.es
new file mode 100644
index 0000000..6e75c04
--- /dev/null
+++ b/core/modules/api.tr.es
@@ -0,0 +1,8 @@
+start=🌐 [api] Ejecutando {method} → {url}
+debug_cmd=🔍 Comando real: {cmd}
+debug_body=📦 [api] Cuerpo enviado:\n{body}
+saved=💾 [api] Respuesta guardada en: {output}
+json_fail=⚠️ [api] No se pudo parsear como JSON
+xml_fail=⚠️ [api] No se pudo parsear como XML
+missing_cmd=⚠️ [api] '{cmd}' no disponible localmente. Se asumirá que existe en el host remoto.
+cmd_ok=✅ [api] '{cmd}' disponible localmente.
diff --git a/core/modules/archive.sh b/core/modules/archive.sh
new file mode 100644
index 0000000..4459bfd
--- /dev/null
+++ b/core/modules/archive.sh
@@ -0,0 +1,113 @@
+#!/usr/bin/env bash
+# Module: archive
+# Description: Comprime, descomprime y extrae archivos en remoto (tar, zip, gzip, bzip2)
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.6.0
+# Dependencies: ssh, tar, gzip, bzip2, zip, unzip
+
+archive_task() {
+ local host="$1"; shift
+ declare -A args
+ local files=()
+
+ for arg in "$@"; do
+ key="${arg%%=*}"; value="${arg#*=}"
+ [[ "$key" == "files" ]] && IFS=',' read -r -a files <<< "$value" || args["$key"]="$value"
+ done
+
+ local action="${args[action]}"
+ local format="${args[format]:-tar}"
+ local become="${args[become]:-false}"
+ local prefix=""
+ [[ "$become" == "true" ]] && prefix="sudo"
+
+ local output="" archive="" dest=""
+ case "$action" in
+ compress) output="${args[output]}" ;;
+ decompress|extract) archive="${args[archive]}"; dest="${args[dest]:-$(dirname "$archive")}" ;;
+ esac
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/archive.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if [[ "$action" == "extract" || "$action" == "decompress" ]]; then
+ ssh "$host" "[ -d '$dest' ] || $prefix mkdir -p '$dest'" || {
+ echo "$(render_msg "${tr[mkdir_fail]}" "dest=$dest")"
+ return 1
+ }
+ fi
+
+ case "$action" in
+ compress)
+ case "$format" in
+ tar)
+ ssh "$host" "$prefix tar -czf '$output' ${files[*]}" && echo "$(render_msg "${tr[compressed_tar]}" "output=$output")"
+ ;;
+ zip)
+ ssh "$host" "$prefix zip -r '$output' ${files[*]}" && echo "$(render_msg "${tr[compressed_zip]}" "output=$output")"
+ ;;
+ gzip)
+ for file in "${files[@]}"; do
+ ssh "$host" "$prefix gzip -f '$file'" && echo "$(render_msg "${tr[compressed_gzip]}" "file=$file")"
+ done
+ ;;
+ bzip2)
+ for file in "${files[@]}"; do
+ ssh "$host" "$prefix bzip2 -f '$file'" && echo "$(render_msg "${tr[compressed_bzip2]}" "file=$file")"
+ done
+ ;;
+ *) echo "$(render_msg "${tr[unsupported_format]}" "format=$format")"; return 1 ;;
+ esac
+ ;;
+ decompress)
+ case "$format" in
+ gzip)
+ ssh "$host" "$prefix gunzip -f '$archive'" && echo "$(render_msg "${tr[decompressed_gzip]}" "archive=$archive")"
+ ;;
+ bzip2)
+ ssh "$host" "$prefix bunzip2 -f '$archive'" && echo "$(render_msg "${tr[decompressed_bzip2]}" "archive=$archive")"
+ ;;
+ zip)
+ ssh "$host" "$prefix unzip -o '$archive' -d '$dest'" && echo "$(render_msg "${tr[decompressed_zip]}" "dest=$dest")"
+ ;;
+ *) echo "$(render_msg "${tr[unsupported_format]}" "format=$format")"; return 1 ;;
+ esac
+ ;;
+ extract)
+ case "$format" in
+ tar)
+ if [[ ${#files[@]} -gt 0 ]]; then
+ ssh "$host" "$prefix tar -xzf '$archive' -C '$dest' ${files[*]}" && echo "$(render_msg "${tr[extracted_tar]}" "dest=$dest")"
+ else
+ ssh "$host" "$prefix tar -xzf '$archive' -C '$dest'" && echo "$(render_msg "${tr[extracted_tar]}" "dest=$dest")"
+ fi
+ ;;
+ zip)
+ ssh "$host" "$prefix unzip -o '$archive' -d '$dest'" && echo "$(render_msg "${tr[extracted_zip]}" "dest=$dest")"
+ ;;
+ *) echo "$(render_msg "${tr[unsupported_format]}" "format=$format")"; return 1 ;;
+ esac
+ ;;
+ *) echo "$(render_msg "${tr[unsupported_action]}" "action=$action")"; return 1 ;;
+ esac
+}
+
+check_dependencies_archive() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/archive.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ for cmd in ssh tar gzip bzip2 zip unzip; do
+ if ! command -v "$cmd" &> /dev/null; then
+ echo "$(render_msg "${tr[missing_cmd]}" "cmd=$cmd")"
+ else
+ echo "$(render_msg "${tr[cmd_ok]}" "cmd=$cmd")"
+ fi
+ done
+ return 0
+}
diff --git a/core/modules/archive.tr.en b/core/modules/archive.tr.en
new file mode 100644
index 0000000..5528413
--- /dev/null
+++ b/core/modules/archive.tr.en
@@ -0,0 +1,14 @@
+mkdir_fail=❌ [archive] Failed to create destination directory: {dest}
+compressed_tar=📦 [archive] Compressed as TAR: {output}
+compressed_zip=📦 [archive] Compressed as ZIP: {output}
+compressed_gzip=📦 [archive] GZIP: {file}.gz
+compressed_bzip2=📦 [archive] BZIP2: {file}.bz2
+decompressed_gzip=📂 [archive] Decompressed GZIP: {archive}
+decompressed_bzip2=📂 [archive] Decompressed BZIP2: {archive}
+decompressed_zip=📂 [archive] Decompressed ZIP into: {dest}
+extracted_tar=📂 [archive] Extracted TAR into: {dest}
+extracted_zip=📂 [archive] Extracted ZIP into: {dest}
+unsupported_format=❌ [archive] Unsupported format '{format}'
+unsupported_action=❌ [archive] Unsupported action '{action}'
+missing_cmd=⚠️ [archive] '{cmd}' not available locally. Assuming it exists on the remote host.
+cmd_ok=✅ [archive] '{cmd}' available locally.
diff --git a/core/modules/archive.tr.es b/core/modules/archive.tr.es
new file mode 100644
index 0000000..86764fd
--- /dev/null
+++ b/core/modules/archive.tr.es
@@ -0,0 +1,14 @@
+mkdir_fail=❌ [archive] No se pudo crear el directorio destino: {dest}
+compressed_tar=📦 [archive] Comprimido en TAR: {output}
+compressed_zip=📦 [archive] Comprimido en ZIP: {output}
+compressed_gzip=📦 [archive] GZIP: {file}.gz
+compressed_bzip2=📦 [archive] BZIP2: {file}.bz2
+decompressed_gzip=📂 [archive] Descomprimido GZIP: {archive}
+decompressed_bzip2=📂 [archive] Descomprimido BZIP2: {archive}
+decompressed_zip=📂 [archive] Descomprimido ZIP en: {dest}
+extracted_tar=📂 [archive] Extraído TAR en: {dest}
+extracted_zip=📂 [archive] Extraído ZIP en: {dest}
+unsupported_format=❌ [archive] Formato '{format}' no soportado
+unsupported_action=❌ [archive] Acción '{action}' no soportada
+missing_cmd=⚠️ [archive] '{cmd}' no disponible localmente. Se asumirá que existe en el host remoto.
+cmd_ok=✅ [archive] '{cmd}' disponible localmente.
diff --git a/core/modules/blockinfile.sh b/core/modules/blockinfile.sh
new file mode 100644
index 0000000..e42f5d8
--- /dev/null
+++ b/core/modules/blockinfile.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# Module: blockinfile
+# Description: Inserta o actualiza bloques de texto delimitados en archivos
+# Author: Luis GuLo
+# Version: 0.2.0
+# Dependencies: grep, sed, tee, awk
+
+blockinfile_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do
+ key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"
+ done
+
+ local path="${args[path]}"
+ local block="${args[block]}"
+ local marker="${args[marker]:-SHFLOW}"
+ local create="${args[create]:-true}"
+ local backup="${args[backup]:-true}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ local start="# BEGIN $marker"
+ local end="# END $marker"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/blockinfile.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if [[ ! -f "$path" ]]; then
+ if [[ "$create" == "true" ]]; then
+ echo "$(render_msg "${tr[creating]}" "path=$path")"
+ touch "$path"
+ else
+ echo "$(render_msg "${tr[missing_file]}" "path=$path")"
+ return 1
+ fi
+ fi
+
+ if [[ "$backup" == "true" ]]; then
+ cp "$path" "$path.bak"
+ echo "$(render_msg "${tr[backup]}" "path=$path")"
+ fi
+
+ if grep -q "$start" "$path"; then
+ echo "$(render_msg "${tr[replacing]}" "marker=$marker")"
+ $prefix sed -i "/$start/,/$end/d" "$path"
+ fi
+
+ echo "$(render_msg "${tr[inserting]}" "marker=$marker")"
+ {
+ echo "$start"
+ echo "$block"
+ echo "$end"
+ } | $prefix tee -a "$path" > /dev/null
+}
+
+check_dependencies_blockinfile() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/blockinfile.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ local missing=()
+ for cmd in grep sed tee awk; do
+ command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
+ done
+
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ echo "$(render_msg "${tr[missing_deps]}" "cmds=${missing[*]}")"
+ return 1
+ fi
+
+ echo "${tr[deps_ok]:-✅ [blockinfile] Todas las dependencias están disponibles}"
+ return 0
+}
diff --git a/core/modules/blockinfile.tr.en b/core/modules/blockinfile.tr.en
new file mode 100644
index 0000000..70a7711
--- /dev/null
+++ b/core/modules/blockinfile.tr.en
@@ -0,0 +1,7 @@
+creating=📄 [blockinfile] Creating file: {path}
+missing_file=❌ [blockinfile] File does not exist and create=false
+backup=📦 Backup created: {path}.bak
+replacing=🔁 [blockinfile] Replacing existing block with marker '{marker}'
+inserting=➕ [blockinfile] Inserting delimited block with marker '{marker}'
+missing_deps=❌ [blockinfile] Missing dependencies: {cmds}
+deps_ok=✅ [blockinfile] All dependencies are available
diff --git a/core/modules/blockinfile.tr.es b/core/modules/blockinfile.tr.es
new file mode 100644
index 0000000..98df5c7
--- /dev/null
+++ b/core/modules/blockinfile.tr.es
@@ -0,0 +1,7 @@
+creating=📄 [blockinfile] Creando archivo: {path}
+missing_file=❌ [blockinfile] El archivo no existe y create=false
+backup=📦 Copia de seguridad creada: {path}.bak
+replacing=🔁 [blockinfile] Reemplazando bloque existente con marcador '{marker}'
+inserting=➕ [blockinfile] Insertando bloque delimitado con marcador '{marker}'
+missing_deps=❌ [blockinfile] Dependencias faltantes: {cmds}
+deps_ok=✅ [blockinfile] Todas las dependencias están disponibles
diff --git a/core/modules/copy.sh b/core/modules/copy.sh
new file mode 100644
index 0000000..b46d9e5
--- /dev/null
+++ b/core/modules/copy.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+# Module: copy
+# Description: Copia archivos locales al host remoto usando scp
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.2.0
+# Dependencies: scp, ssh
+
+copy_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do
+ key="${arg%%=*}"
+ value="${arg#*=}"
+ args["$key"]="$value"
+ done
+
+ local src="${args[src]}"
+ local dest="${args[dest]}"
+ local mode="${args[mode]}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/copy.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if [[ -z "$src" || -z "$dest" ]]; then
+ echo "${tr[missing_args]:-❌ [copy] Faltan parámetros: src y dest son obligatorios}"
+ return 1
+ fi
+
+ echo "$(render_msg "${tr[copying]}" "src=$src" "host=$host")"
+ scp "$src" "$host:/tmp/shflow_tmpfile" || {
+ echo "$(render_msg "${tr[scp_fail]}" "src=$src" "host=$host")"
+ return 1
+ }
+
+ echo "$(render_msg "${tr[moving]}" "dest=$dest")"
+ ssh "$host" "$prefix mv /tmp/shflow_tmpfile '$dest' && $prefix chmod $mode '$dest'" && \
+ echo "$(render_msg "${tr[done]}" "dest=$dest")"
+}
+
+check_dependencies_copy() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/copy.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ local missing=()
+ for cmd in scp ssh; do
+ command -v "$cmd" &> /dev/null || missing+=("$cmd")
+ done
+
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ echo "$(render_msg "${tr[missing_deps]}" "cmds=${missing[*]}")"
+ return 1
+ fi
+
+ echo "${tr[deps_ok]:-✅ [copy] Todas las dependencias están disponibles}"
+ return 0
+}
diff --git a/core/modules/copy.tr.en b/core/modules/copy.tr.en
new file mode 100644
index 0000000..dcace81
--- /dev/null
+++ b/core/modules/copy.tr.en
@@ -0,0 +1,7 @@
+missing_args=❌ [copy] Missing parameters: src and dest are required
+copying=📤 [copy] Copying '{src}' to host '{host}'...
+scp_fail=❌ [copy] Failed to copy '{src}' to host '{host}'
+moving=📦 [copy] Moving file to final destination: '{dest}'
+done=✅ [copy] File successfully installed at '{dest}'
+missing_deps=❌ [copy] Missing dependencies: {cmds}
+deps_ok=✅ [copy] All dependencies are available
diff --git a/core/modules/copy.tr.es b/core/modules/copy.tr.es
new file mode 100644
index 0000000..9b5f934
--- /dev/null
+++ b/core/modules/copy.tr.es
@@ -0,0 +1,7 @@
+missing_args=❌ [copy] Faltan parámetros: src y dest son obligatorios
+copying=📤 [copy] Copiando '{src}' al host '{host}'...
+scp_fail=❌ [copy] Falló la copia de '{src}' al host '{host}'
+moving=📦 [copy] Moviendo archivo a destino final: '{dest}'
+done=✅ [copy] Archivo instalado correctamente en '{dest}'
+missing_deps=❌ [copy] Dependencias faltantes: {cmds}
+deps_ok=✅ [copy] Todas las dependencias están disponibles
diff --git a/core/modules/cron.sh b/core/modules/cron.sh
new file mode 100644
index 0000000..c1c4533
--- /dev/null
+++ b/core/modules/cron.sh
@@ -0,0 +1,97 @@
+#!/bin/bash
+# Module: cron
+# Description: Gestiona entradas de cron para usuarios del sistema (crear, modificar, eliminar, listar)
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.1.0
+# Dependencies: bash, crontab, grep, id, sudo
+
+cron_task() {
+ local host="$1"; shift
+ local alias="" user="" state="" schedule="" command=""
+ for arg in "$@"; do
+ case "$arg" in
+ alias=*) alias="${arg#alias=}" ;;
+ user=*) user="${arg#user=}" ;;
+ state=*) state="${arg#state=}" ;;
+ schedule=*) schedule="${arg#schedule=}" ;;
+ command=*) command="${arg#command=}" ;;
+ esac
+ done
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/cron.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if [[ -z "$user" || -z "$state" ]]; then
+ echo "${tr[missing_args]:-❌ [cron] Faltan argumentos obligatorios: 'user' y 'state'}"
+ return 1
+ fi
+
+ if ! id "$user" &>/dev/null; then
+ echo "$(render_msg "${tr[user_not_found]}" "user=$user")"
+ return 1
+ fi
+
+ local tag="# shflow:$alias"
+ local tmpfile
+ tmpfile=$(mktemp)
+
+ echo "$(render_msg "${tr[checking]}" "user=$user")"
+ sudo crontab -u "$user" -l 2>/dev/null > "$tmpfile" || true
+
+ case "$state" in
+ list)
+ echo "$(render_msg "${tr[list]}" "user=$user")"
+ grep -E "^.*$tag|^[^#]" "$tmpfile" || echo "${tr[no_entries]:-⚠️ [cron] No hay entradas visibles}"
+ rm -f "$tmpfile"
+ return 0
+ ;;
+ absent)
+ if grep -q "$tag" "$tmpfile"; then
+ grep -v "$tag" "$tmpfile" > "${tmpfile}.new"
+ sudo crontab -u "$user" "${tmpfile}.new"
+ echo "$(render_msg "${tr[removed]}" "alias=$alias")"
+ rm -f "${tmpfile}.new"
+ else
+ echo "$(render_msg "${tr[not_found]}" "alias=$alias")"
+ fi
+ rm -f "$tmpfile"
+ return 0
+ ;;
+ present)
+ if [[ -z "$alias" || -z "$schedule" || -z "$command" ]]; then
+ echo "${tr[missing_present]:-❌ [cron] Para 'present' se requieren: alias, schedule y command}"
+ rm -f "$tmpfile"
+ return 1
+ fi
+ grep -v "$tag" "$tmpfile" > "${tmpfile}.new"
+ echo "$schedule $command $tag" >> "${tmpfile}.new"
+ sudo crontab -u "$user" "${tmpfile}.new"
+ echo "$(render_msg "${tr[added]}" "alias=$alias")"
+ rm -f "$tmpfile" "${tmpfile}.new"
+ return 0
+ ;;
+ *)
+ echo "$(render_msg "${tr[unsupported]}" "state=$state")"
+ rm -f "$tmpfile"
+ return 1
+ ;;
+ esac
+}
+
+check_dependencies_cron() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/cron.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if ! command -v sudo &>/dev/null; then
+ echo "${tr[missing_sudo]:-❌ [cron] El comando 'sudo' no está disponible}"
+ return 1
+ fi
+ echo "${tr[deps_ok]:-✅ [cron] Dependencias OK}"
+ return 0
+}
diff --git a/core/modules/cron.tr.en b/core/modules/cron.tr.en
new file mode 100644
index 0000000..404c5f0
--- /dev/null
+++ b/core/modules/cron.tr.en
@@ -0,0 +1,12 @@
+missing_args=❌ [cron] Missing required arguments: 'user' and 'state'
+user_not_found=❌ [cron] User '{user}' does not exist on the system
+checking=🕒 [cron] Checking cron entries for user '{user}'...
+list=📋 [cron] Current entries for '{user}':
+no_entries=⚠️ [cron] No visible entries
+removed=➖ [cron] Entry '{alias}' successfully removed
+not_found=⚠️ [cron] Entry '{alias}' not found, nothing removed
+missing_present=❌ [cron] For 'present', alias, schedule and command are required
+added=➕ [cron] Entry '{alias}' successfully created/modified
+unsupported=❌ [cron] Unknown state: '{state}'. Use 'present', 'absent' or 'list'
+missing_sudo=❌ [cron] 'sudo' command is not available
+deps_ok=✅ [cron] Dependencies OK
diff --git a/core/modules/cron.tr.es b/core/modules/cron.tr.es
new file mode 100644
index 0000000..683e2f8
--- /dev/null
+++ b/core/modules/cron.tr.es
@@ -0,0 +1,12 @@
+missing_args=❌ [cron] Faltan argumentos obligatorios: 'user' y 'state'
+user_not_found=❌ [cron] El usuario '{user}' no existe en el sistema
+checking=🕒 [cron] Revisando entradas de cron para usuario '{user}'...
+list=📋 [cron] Entradas actuales para '{user}':
+no_entries=⚠️ [cron] No hay entradas visibles
+removed=➖ [cron] Entrada '{alias}' eliminada correctamente
+not_found=⚠️ [cron] Entrada '{alias}' no encontrada, no se eliminó nada
+missing_present=❌ [cron] Para 'present' se requieren: alias, schedule y command
+added=➕ [cron] Entrada '{alias}' creada/modificada correctamente
+unsupported=❌ [cron] Estado desconocido: '{state}'. Usa 'present', 'absent' o 'list'
+missing_sudo=❌ [cron] El comando 'sudo' no está disponible
+deps_ok=✅ [cron] Dependencias OK
diff --git a/core/modules/docker.sh b/core/modules/docker.sh
new file mode 100644
index 0000000..9d3350e
--- /dev/null
+++ b/core/modules/docker.sh
@@ -0,0 +1,81 @@
+#!/bin/bash
+# License: GPLv3
+# Module: docker
+# Description: Gestiona contenedores Docker (run, stop, remove, build, exec)
+# Author: Luis GuLo
+# Version: 1.7.0
+# Dependencies: ssh, docker
+
+docker_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local action="${args[action]}"
+ local become="${args[become]:-false}"
+ local detach="${args[detach]:-true}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+ local detached="-d"
+ [ "$detach" = "false" ] && detached=""
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/docker.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ local name="" image="" path="" command=""
+ case "$action" in present|stopped|absent|exec) name="${args[name]}" ;; esac
+ case "$action" in present|build) image="${args[image]}" ;; esac
+ [[ "$action" == "build" ]] && path="${args[path]}"
+ [[ "$action" == "exec" ]] && command="${args[command]}"
+
+ case "$action" in
+ present)
+ local extra="${args[run_args]:-${args[extra_args]:-}}"
+ echo "$(render_msg "${tr[run]}" "name=$name" "image=$image")"
+ ssh "$host" "$prefix docker ps -a --format '{{.Names}}' | grep -q '^$name$' || $prefix docker run $detached --name '$name' $extra '$image'"
+ ;;
+ stopped)
+ echo "$(render_msg "${tr[stop]}" "name=$name")"
+ ssh "$host" "$prefix docker ps --format '{{.Names}}' | grep -q '^$name$' && $prefix docker stop '$name'"
+ ;;
+ absent)
+ echo "$(render_msg "${tr[remove]}" "name=$name")"
+ ssh "$host" "$prefix docker ps -a --format '{{.Names}}' | grep -q '^$name$' && $prefix docker rm -f '$name'"
+ ;;
+ build)
+ echo "$(render_msg "${tr[build]}" "image=$image" "path=$path")"
+ ssh "$host" "cd '$path' && $prefix docker build -t '$image' ."
+ ;;
+ exec)
+ echo "$(render_msg "${tr[exec]}" "name=$name" "command=$command")"
+ ssh "$host" "$prefix docker exec '$name' $command"
+ ;;
+ *)
+ echo "$(render_msg "${tr[unsupported]}" "action=$action")"
+ return 1
+ ;;
+ esac
+}
+
+check_dependencies_docker() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/docker.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if ! command -v ssh &> /dev/null; then
+ echo "${tr[missing_ssh]:-❌ [docker] ssh no está disponible.}"
+ return 1
+ fi
+ echo "${tr[ssh_ok]:-✅ [docker] ssh disponible.}"
+
+ if ! command -v docker &> /dev/null; then
+ echo "${tr[missing_docker]:-⚠️ [docker] docker no disponible localmente. Se asumirá que existe en el host remoto.}"
+ else
+ echo "${tr[docker_ok]:-✅ [docker] docker disponible localmente.}"
+ fi
+ return 0
+}
diff --git a/core/modules/docker.tr.en b/core/modules/docker.tr.en
new file mode 100644
index 0000000..59844f2
--- /dev/null
+++ b/core/modules/docker.tr.en
@@ -0,0 +1,10 @@
+run=🧪 [docker] Running container '{name}' with image '{image}'
+stop=🛑 [docker] Stopping container '{name}'
+remove=🧹 [docker] Removing container '{name}'
+build=🏗️ [docker] Building image '{image}' from '{path}'
+exec=🚀 [docker] Executing command in '{name}': {command}
+unsupported=❌ [docker] Unsupported action '{action}'
+missing_ssh=❌ [docker] ssh is not available.
+ssh_ok=✅ [docker] ssh is available.
+missing_docker=⚠️ [docker] docker not available locally. Assuming it exists on the remote host.
+docker_ok=✅ [docker] docker available locally.
diff --git a/core/modules/docker.tr.es b/core/modules/docker.tr.es
new file mode 100644
index 0000000..4abe373
--- /dev/null
+++ b/core/modules/docker.tr.es
@@ -0,0 +1,10 @@
+run=🧪 [docker] Ejecutando contenedor '{name}' con imagen '{image}'
+stop=🛑 [docker] Deteniendo contenedor '{name}'
+remove=🧹 [docker] Eliminando contenedor '{name}'
+build=🏗️ [docker] Construyendo imagen '{image}' desde '{path}'
+exec=🚀 [docker] Ejecutando comando en '{name}': {command}
+unsupported=❌ [docker] Acción '{action}' no soportada
+missing_ssh=❌ [docker] ssh no está disponible.
+ssh_ok=✅ [docker] ssh disponible.
+missing_docker=⚠️ [docker] docker no disponible localmente. Se asumirá que existe en el host remoto.
+docker_ok=✅ [docker] docker disponible localmente.
diff --git a/core/modules/download.sh b/core/modules/download.sh
new file mode 100644
index 0000000..a71fcc4
--- /dev/null
+++ b/core/modules/download.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# Module: download
+# Description: Descarga ficheros remotos con soporte para reintentos, proxy y reanudación
+# Author: Luis GuLo
+# Version: 1.1.0
+# Dependencies: wget o curl, sudo (si become=true)
+
+download_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local url="${args[url]}"
+ local dest="${args[dest]:-$(basename "$url")}"
+ local proxy="${args[proxy]:-}"
+ local continue="${args[continue]:-true}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/download.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if [[ -z "$url" ]]; then
+ echo "${tr[missing_url]:-❌ [download] Falta el parámetro obligatorio 'url'}"
+ return 1
+ fi
+
+ local cmd=""
+ if command -v wget &>/dev/null; then
+ echo "${tr[using_wget]:-📦 [download] Usando wget}"
+ cmd="$prefix wget \"$url\" -O \"$dest\""
+ [[ "$continue" == "true" ]] && cmd="$cmd -c"
+ [[ -n "$proxy" ]] && cmd="$cmd -e use_proxy=yes -e http_proxy=\"$proxy\""
+ elif command -v curl &>/dev/null; then
+ echo "${tr[using_curl]:-📦 [download] Usando curl}"
+ cmd="$prefix curl -L \"$url\" -o \"$dest\""
+ [[ "$continue" == "true" ]] && cmd="$cmd -C -"
+ [[ -n "$proxy" ]] && cmd="$cmd --proxy \"$proxy\""
+ else
+ echo "${tr[missing_tool]:-❌ [download] Ni wget ni curl están disponibles}"
+ return 1
+ fi
+
+ echo "$(render_msg "${tr[start]}" "url=$url" "dest=$dest")"
+ eval "$cmd" && echo "$(render_msg "${tr[done]}" "dest=$dest")"
+}
+
+check_dependencies_download() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/download.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ if ! command -v wget &>/dev/null && ! command -v curl &>/dev/null; then
+ echo "${tr[missing_tool]:-❌ [download] Se requiere 'wget' o 'curl'}"
+ return 1
+ fi
+ echo "${tr[deps_ok]:-✅ [download] Herramienta de descarga disponible}"
+ return 0
+}
diff --git a/core/modules/download.tr.en b/core/modules/download.tr.en
new file mode 100644
index 0000000..04526c4
--- /dev/null
+++ b/core/modules/download.tr.en
@@ -0,0 +1,7 @@
+missing_url=❌ [download] Missing required parameter 'url'
+using_wget=📦 [download] Using wget
+using_curl=📦 [download] Using curl
+missing_tool=❌ [download] Neither wget nor curl are available
+start=🔧 [download] Downloading '{url}' → '{dest}'
+done=✅ [download] Download completed: {dest}
+deps_ok=✅ [download] Download tool available
diff --git a/core/modules/download.tr.es b/core/modules/download.tr.es
new file mode 100644
index 0000000..d4f54d3
--- /dev/null
+++ b/core/modules/download.tr.es
@@ -0,0 +1,7 @@
+missing_url=❌ [download] Falta el parámetro obligatorio 'url'
+using_wget=📦 [download] Usando wget
+using_curl=📦 [download] Usando curl
+missing_tool=❌ [download] Ni wget ni curl están disponibles
+start=🔧 [download] Descargando '{url}' → '{dest}'
+done=✅ [download] Descarga completada: {dest}
+deps_ok=✅ [download] Herramienta de descarga disponible
diff --git a/core/modules/echo.sh b/core/modules/echo.sh
new file mode 100644
index 0000000..8b562a5
--- /dev/null
+++ b/core/modules/echo.sh
@@ -0,0 +1,51 @@
+#!/bin/bash
+# Module: echo
+# Description: Muestra un mensaje en consola con soporte para variables ShFlow
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.2.0
+# Dependencies: -
+
+echo_task() {
+ local host="$1"; shift
+ declare -A args
+
+ while [[ "$#" -gt 0 ]]; do
+ case "$1" in
+ *=*)
+ key="${1%%=*}"
+ value="${1#*=}"
+ args["$key"]="$value"
+ ;;
+ esac
+ shift
+ done
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/echo.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ # 🔁 Interpolar usando argumentos explícitos
+ for key in "${!args[@]}"; do
+ for var in "${!args[@]}"; do
+ args["$key"]="${args[$key]//\{\{ $var \}\}/${args[$var]}}"
+ done
+ done
+
+ local message="${args[message]}"
+ echo "$(render_msg "${tr[output]}" "message=$message")"
+}
+
+check_dependencies_echo() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/echo.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ echo "${tr[deps_ok]:-✅ [echo] No requiere dependencias.}"
+ return 0
+}
diff --git a/core/modules/echo.tr.en b/core/modules/echo.tr.en
new file mode 100644
index 0000000..ab2154a
--- /dev/null
+++ b/core/modules/echo.tr.en
@@ -0,0 +1,2 @@
+output=📣 [echo] {message}
+deps_ok=✅ [echo] No dependencies required.
diff --git a/core/modules/echo.tr.es b/core/modules/echo.tr.es
new file mode 100644
index 0000000..ae12ef8
--- /dev/null
+++ b/core/modules/echo.tr.es
@@ -0,0 +1,2 @@
+output=📣 [echo] {message}
+deps_ok=✅ [echo] No requiere dependencias.
diff --git a/core/modules/facts.sh b/core/modules/facts.sh
new file mode 100644
index 0000000..498ccd0
--- /dev/null
+++ b/core/modules/facts.sh
@@ -0,0 +1,121 @@
+#!/usr/bin/env bash
+# Module: facts
+# Description: Extrae información del sistema con opciones de formato, filtrado y salida
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.4.0
+# Dependencies: lscpu, ip, free, lsblk, uname, hostnamectl
+
+facts_task() {
+ local host="$1"; shift
+ declare -A args
+ local field="" format="plain" output="" append="false" host_label=""
+
+ for arg in "$@"; do
+ key="${arg%%=*}"; value="${arg#*=}"
+ case "$key" in
+ field) field="$value" ;;
+ format) format="${value,,}" ;;
+ output) output="$value" ;;
+ append) append="$value" ;;
+ host_label) host_label="$value" ;;
+ esac
+ done
+
+ [[ -z "$host_label" ]] && host_label="$host"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/facts.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ local prefix=""
+ [[ "$host" != "localhost" ]] && prefix="ssh $host"
+
+ [[ "$DEBUG" == "true" ]] && echo "$(render_msg "${tr[debug_prefix]}" "prefix=$prefix")"
+
+ local raw
+ raw=$($prefix bash --noprofile --norc <<'EOF'
+ cd /tmp || cd ~
+ echo "hostname=$(hostname)"
+ lscpu | awk '/^CPU\(s\):/ {print "cpu_count="$2}'
+ free -m | awk '/Mem:/ {print "ram_total_mb="$2}'
+ if command -v hostnamectl &> /dev/null; then
+ hostnamectl | awk -F: '/Operating System/ {print "os_name=" $2}' | sed 's/^ *//'
+ hostnamectl | awk -F: '/Kernel/ {print "os_version=" $2}' | sed 's/^ *//'
+ else
+ echo "os_name=$(uname -s)"
+ echo "os_version=$(uname -r)"
+ fi
+ ip link show | awk -F: '/^[0-9]+: / {print $2}' | grep -Ev 'docker|virbr|lo|veth|br-' | while read -r dev; do
+ ip=$(ip -4 addr show "$dev" | awk '/inet / {print $2}' | cut -d/ -f1)
+ mac=$(ip link show "$dev" | awk '/ether/ {print $2}')
+ [[ -n "$ip" || -n "$mac" ]] && echo "net_$dev=IP:$ip MAC:$mac"
+ done
+ ip -4 addr show | awk '/inet / {print $2}' | cut -d/ -f1 | paste -sd ' ' - | awk '{print "ip_addresses="$0}'
+ lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT | grep -Ev 'loop|tmpfs|overlay|docker' | awk 'NR>1 && NF>0 {print "partition_list=" $1 " " $2 " " $3 " " $4}'
+EOF
+)
+
+ [[ -n "$field" ]] && raw=$(echo "$raw" | grep "^$field=")
+
+ local partitions=() facts=()
+ while IFS= read -r line; do
+ [[ "$line" == partition_list=* ]] && partitions+=("${line#*=}") || facts+=("$line")
+ done <<< "$raw"
+
+ local formatted=""
+ case "$format" in
+ plain)
+ formatted+="Host: $host_label\n"
+ for f in "${facts[@]}"; do formatted+="${f%%=*}: ${f#*=}\n"; done
+ [[ ${#partitions[@]} -gt 0 ]] && formatted+="partitions:\n" && for p in "${partitions[@]}"; do formatted+=" - $p\n"; done
+ ;;
+ md)
+ formatted+="### $host_label\n"
+ for f in "${facts[@]}"; do formatted+="- **${f%%=*}:** ${f#*=}\n"; done
+ [[ ${#partitions[@]} -gt 0 ]] && formatted+="- **partitions:**\n" && for p in "${partitions[@]}"; do formatted+=" - $p\n"; done
+ ;;
+ kv)
+ for f in "${facts[@]}"; do formatted+="$f\n"; done
+ [[ ${#partitions[@]} -gt 0 ]] && formatted+="partitions=$(IFS=';'; echo "${partitions[*]}")\n"
+ ;;
+ json)
+ local json="{"
+ for f in "${facts[@]}"; do json+="\"${f%%=*}\":\"${f#*=}\","; done
+ [[ ${#partitions[@]} -gt 0 ]] && json+="\"partitions\":[" && for p in "${partitions[@]}"; do json+="\"$p\","; done && json="${json%,}]" || json="${json%,}"
+ json+="}"
+ formatted="$json"
+ ;;
+ *)
+ echo "$(render_msg "${tr[unsupported_format]}" "format=$format")"
+ return 1
+ ;;
+ esac
+
+ if [[ -n "$output" ]]; then
+ [[ "$append" == "true" ]] && echo -e "$formatted" >> "$output" || echo -e "$formatted" > "$output"
+ echo "$(render_msg "${tr[saved]}" "output=$output")"
+ else
+ echo -e "$formatted"
+ fi
+}
+
+check_dependencies_facts() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/facts.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+ for cmd in lscpu ip free lsblk uname hostnamectl; do
+ if ! command -v "$cmd" &> /dev/null; then
+ echo "$(render_msg "${tr[missing_cmd]}" "cmd=$cmd")"
+ else
+ echo "$(render_msg "${tr[cmd_ok]}" "cmd=$cmd")"
+ fi
+ done
+ return 0
+}
diff --git a/core/modules/facts.tr.en b/core/modules/facts.tr.en
new file mode 100644
index 0000000..015ef5b
--- /dev/null
+++ b/core/modules/facts.tr.en
@@ -0,0 +1,5 @@
+debug_prefix=🔍 SSH line: {prefix}
+unsupported_format=❌ [facts] Format '{format}' not supported.
+saved=💾 [facts] Report saved to: {output}
+missing_cmd=⚠️ [facts] '{cmd}' not available locally. Assuming it exists on remote host.
+cmd_ok=✅ [facts] '{cmd}' available locally.
diff --git a/core/modules/facts.tr.es b/core/modules/facts.tr.es
new file mode 100644
index 0000000..7d2b08b
--- /dev/null
+++ b/core/modules/facts.tr.es
@@ -0,0 +1,5 @@
+debug_prefix=🔍 Línea SSH: {prefix}
+unsupported_format=❌ [facts] Formato '{format}' no soportado.
+saved=💾 [facts] Informe guardado en: {output}
+missing_cmd=⚠️ [facts] '{cmd}' no disponible localmente. Se asumirá que existe en el host remoto.
+cmd_ok=✅ [facts] '{cmd}' disponible localmente.
diff --git a/core/modules/file.sh b/core/modules/file.sh
new file mode 100644
index 0000000..44804af
--- /dev/null
+++ b/core/modules/file.sh
@@ -0,0 +1,78 @@
+#!/bin/bash
+# Module: file
+# Description: Gestiona archivos y directorios remotos (crear, eliminar, permisos)
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.2.0
+# Dependencies: ssh
+
+file_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do
+ key="${arg%%=*}"
+ value="${arg#*=}"
+ args["$key"]="$value"
+ done
+
+ local path="${args[path]}"
+ local state="${args[state]}"
+ local type="${args[type]}"
+ local mode="${args[mode]}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/file.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ case "$state" in
+ present)
+ if [[ "$type" == "directory" ]]; then
+ echo "$(render_msg "${tr[creating_dir]}" "path=$path")"
+ ssh "$host" "[ -d '$path' ] || $prefix mkdir -p '$path'"
+ elif [[ "$type" == "file" ]]; then
+ echo "$(render_msg "${tr[creating_file]}" "path=$path")"
+ ssh "$host" "[ -f '$path' ] || $prefix touch '$path'"
+ fi
+ if [[ -n "$mode" ]]; then
+ echo "$(render_msg "${tr[setting_mode]}" "mode=$mode" "path=$path")"
+ ssh "$host" "$prefix chmod $mode '$path'"
+ fi
+ ;;
+ absent)
+ if [[ "$type" == "directory" ]]; then
+ echo "$(render_msg "${tr[removing_dir]}" "path=$path")"
+ ssh "$host" "[ -d '$path' ] && $prefix rm -rf '$path'"
+ elif [[ "$type" == "file" ]]; then
+ echo "$(render_msg "${tr[removing_file]}" "path=$path")"
+ ssh "$host" "[ -f '$path' ] && $prefix rm -f '$path'"
+ fi
+ ;;
+ *)
+ echo "$(render_msg "${tr[unsupported_state]}" "state=$state")"
+ return 1
+ ;;
+ esac
+}
+
+check_dependencies_file() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/file.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ if ! command -v ssh &> /dev/null; then
+ echo "${tr[missing_deps]:-❌ [file] ssh no está disponible.}"
+ return 1
+ fi
+ echo "${tr[deps_ok]:-✅ [file] ssh disponible.}"
+ return 0
+}
diff --git a/core/modules/file.tr.en b/core/modules/file.tr.en
new file mode 100644
index 0000000..2d11c91
--- /dev/null
+++ b/core/modules/file.tr.en
@@ -0,0 +1,8 @@
+creating_dir=📁 [file] Creating directory: {path}
+creating_file=📄 [file] Creating file: {path}
+setting_mode=🔐 [file] Setting permissions {mode} on {path}
+removing_dir=🧹 [file] Removing directory: {path}
+removing_file=🧹 [file] Removing file: {path}
+unsupported_state=❌ [file] Unsupported state '{state}'. Use present or absent.
+missing_deps=❌ [file] ssh is not available.
+deps_ok=✅ [file] ssh is available.
diff --git a/core/modules/file.tr.es b/core/modules/file.tr.es
new file mode 100644
index 0000000..4123cad
--- /dev/null
+++ b/core/modules/file.tr.es
@@ -0,0 +1,8 @@
+creating_dir=📁 [file] Creando directorio: {path}
+creating_file=📄 [file] Creando archivo: {path}
+setting_mode=🔐 [file] Estableciendo permisos {mode} en {path}
+removing_dir=🧹 [file] Eliminando directorio: {path}
+removing_file=🧹 [file] Eliminando archivo: {path}
+unsupported_state=❌ [file] Estado '{state}' no soportado. Usa present o absent.
+missing_deps=❌ [file] ssh no está disponible.
+deps_ok=✅ [file] ssh disponible.
diff --git a/core/modules/file_read.sh b/core/modules/file_read.sh
new file mode 100644
index 0000000..e21423f
--- /dev/null
+++ b/core/modules/file_read.sh
@@ -0,0 +1,56 @@
+#!/bin/bash
+# Module: file_read
+# Description: Lee el contenido de un archivo remoto, con opción de filtrado por patrón
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.1.0
+# Dependencies: ssh, cat, grep
+
+file_read_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local path="${args[path]}"
+ local grep="${args[grep]}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/file_read.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ if [[ -z "$path" ]]; then
+ echo "${tr[missing_path]:-❌ [file_read] Parámetro 'path' obligatorio}"
+ return 1
+ fi
+
+ echo "$(render_msg "${tr[start]}" "path=$path" "host=$host")"
+
+ if [[ -n "$grep" ]]; then
+ ssh "$host" "$prefix grep -E '$grep' '$path'"
+ else
+ ssh "$host" "$prefix cat '$path'"
+ fi
+}
+
+check_dependencies_file_read() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/file_read.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ if ! command -v ssh &> /dev/null || ! command -v grep &> /dev/null; then
+ echo "${tr[missing_deps]:-❌ [file_read] ssh o grep no están disponibles.}"
+ return 1
+ fi
+ echo "${tr[deps_ok]:-✅ [file_read] ssh y grep disponibles.}"
+ return 0
+}
diff --git a/core/modules/file_read.tr.en b/core/modules/file_read.tr.en
new file mode 100644
index 0000000..117764e
--- /dev/null
+++ b/core/modules/file_read.tr.en
@@ -0,0 +1,4 @@
+missing_path=❌ [file_read] Parameter 'path' is required
+start=📄 [file_read] Reading file '{path}' on {host}...
+missing_deps=❌ [file_read] ssh or grep are not available.
+deps_ok=✅ [file_read] ssh and grep are available.
diff --git a/core/modules/file_read.tr.es b/core/modules/file_read.tr.es
new file mode 100644
index 0000000..0331aa7
--- /dev/null
+++ b/core/modules/file_read.tr.es
@@ -0,0 +1,4 @@
+missing_path=❌ [file_read] Parámetro 'path' obligatorio
+start=📄 [file_read] Leyendo archivo '{path}' en {host}...
+missing_deps=❌ [file_read] ssh o grep no están disponibles.
+deps_ok=✅ [file_read] ssh y grep disponibles.
diff --git a/core/modules/fs.sh b/core/modules/fs.sh
new file mode 100644
index 0000000..1b16bad
--- /dev/null
+++ b/core/modules/fs.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# Module: fs
+# Description: Operaciones remotas sobre ficheros (mover, renombrar, copiar, borrar, truncar)
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.3.0
+# Dependencies: ssh
+
+fs_task() {
+ local host="$1"; shift
+ declare -A args
+ local files=()
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/fs.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ # Parseo de argumentos
+ for arg in "$@"; do
+ key="${arg%%=*}"
+ value="${arg#*=}"
+ if [[ "$key" == "files" ]]; then
+ if [[ "$value" == *'*'* || "$value" == *'?'* || "$value" == *'['* ]]; then
+ mapfile -t files < <(ssh "$host" "ls -1 $value 2>/dev/null")
+ else
+ IFS=',' read -r -a files <<< "$value"
+ fi
+ else
+ args["$key"]="$value"
+ fi
+ done
+
+ local action="${args[action]}"
+ local src="${args[src]}"
+ local dest="${args[dest]}"
+ local path="${args[path]}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ case "$action" in
+ move|rename|copy)
+ if [[ ${#files[@]} -gt 0 ]]; then
+ for file in "${files[@]}"; do
+ base="$(basename "$file")"
+ target="$dest/$base"
+ cmd="$prefix mv"; [[ "$action" == "copy" ]] && cmd="$prefix cp"
+ ssh "$host" "$cmd '$file' '$target'" && echo "$(render_msg "${tr[action_ok]}" "action=$action" "src=$file" "dest=$target")"
+ done
+ else
+ cmd="$prefix mv"; [[ "$action" == "copy" ]] && cmd="$prefix cp"
+ ssh "$host" "$cmd '$src' '$dest'" && echo "$(render_msg "${tr[action_ok]}" "action=$action" "src=$src" "dest=$dest")"
+ fi
+ ;;
+ delete|truncate)
+ if [[ ${#files[@]} -gt 0 ]]; then
+ for file in "${files[@]}"; do
+ cmd="$prefix rm -f"; [[ "$action" == "truncate" ]] && cmd="$prefix truncate -s 0"
+ ssh "$host" "$cmd '$file'" && echo "$(render_msg "${tr[action_ok]}" "action=$action" "src=$file")"
+ done
+ else
+ cmd="$prefix rm -f"; [[ "$action" == "truncate" ]] && cmd="$prefix truncate -s 0"
+ ssh "$host" "$cmd '$path'" && echo "$(render_msg "${tr[action_ok]}" "action=$action" "src=$path")"
+ fi
+ ;;
+ *)
+ echo "$(render_msg "${tr[unsupported]}" "action=$action")"
+ return 1
+ ;;
+ esac
+}
+
+check_dependencies_fs() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/fs.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ if ! command -v ssh &> /dev/null; then
+ echo "${tr[missing_deps]:-❌ [fs] ssh no está disponible.}"
+ return 1
+ fi
+ echo "${tr[deps_ok]:-✅ [fs] ssh disponible.}"
+ return 0
+}
diff --git a/core/modules/fs.tr.en b/core/modules/fs.tr.en
new file mode 100644
index 0000000..4e0b2ef
--- /dev/null
+++ b/core/modules/fs.tr.en
@@ -0,0 +1,4 @@
+action_ok=📁 [{action}] {src} → {dest}
+unsupported=❌ [fs] Unsupported action '{action}'.
+missing_deps=❌ [fs] ssh is not available.
+deps_ok=✅ [fs] ssh is available.
diff --git a/core/modules/fs.tr.es b/core/modules/fs.tr.es
new file mode 100644
index 0000000..35f3e0a
--- /dev/null
+++ b/core/modules/fs.tr.es
@@ -0,0 +1,4 @@
+action_ok=📁 [{action}] {src} → {dest}
+unsupported=❌ [fs] Acción '{action}' no soportada.
+missing_deps=❌ [fs] ssh no está disponible.
+deps_ok=✅ [fs] ssh disponible.
diff --git a/core/modules/git.sh b/core/modules/git.sh
new file mode 100644
index 0000000..0fa22f1
--- /dev/null
+++ b/core/modules/git.sh
@@ -0,0 +1,88 @@
+#!/bin/bash
+# Module: git
+# Description: Gestiona repositorios Git en hosts remotos (clone, pull, checkout, fetch-file)
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.2.0
+# Dependencies: ssh, git, curl, tar
+
+git_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local action="${args[action]}"
+ local repo="${args[repo]}"
+ local dest="${args[dest]}"
+ local branch="${args[branch]}"
+ local file_path="${args[file_path]}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/git.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ case "$action" in
+ clone)
+ echo "$(render_msg "${tr[cloning]}" "repo=$repo" "dest=$dest")"
+ ssh "$host" "[ -d '$dest/.git' ] || $prefix git clone '$repo' '$dest'"
+ ;;
+ pull)
+ echo "$(render_msg "${tr[pulling]}" "dest=$dest")"
+ ssh "$host" "[ -d '$dest/.git' ] && cd '$dest' && $prefix git pull"
+ ;;
+ checkout)
+ echo "$(render_msg "${tr[checkout]}" "branch=$branch" "dest=$dest")"
+ ssh "$host" "[ -d '$dest/.git' ] && cd '$dest' && $prefix git checkout '$branch'"
+ ;;
+ fetch-file)
+ echo "$(render_msg "${tr[fetching]}" "file=$file_path" "repo=$repo" "branch=$branch")"
+ fetch_file_from_repo "$host" "$repo" "$branch" "$file_path" "$dest" "$become"
+ ;;
+ *)
+ echo "$(render_msg "${tr[unsupported]}" "action=$action")"
+ return 1
+ ;;
+ esac
+}
+
+fetch_file_from_repo() {
+ local host="$1"
+ local repo="$2"
+ local branch="$3"
+ local file_path="$4"
+ local dest="$5"
+ local become="$6"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ ssh "$host" "$prefix git archive --remote='$repo' '$branch' '$file_path' | $prefix tar -xO > '$dest'"
+}
+
+check_dependencies_git() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/git.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ local missing=()
+ for cmd in ssh git curl tar; do
+ command -v "$cmd" &> /dev/null || missing+=("$cmd")
+ done
+
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ echo "$(render_msg "${tr[missing_deps]}" "cmds=${missing[*]}")"
+ return 1
+ fi
+
+ echo "${tr[deps_ok]:-✅ [git] Todas las dependencias están disponibles}"
+ return 0
+}
diff --git a/core/modules/git.tr.en b/core/modules/git.tr.en
new file mode 100644
index 0000000..ca27acd
--- /dev/null
+++ b/core/modules/git.tr.en
@@ -0,0 +1,7 @@
+cloning=📥 [git] Cloning repository '{repo}' into '{dest}'
+pulling=🔄 [git] Running git pull in '{dest}'
+checkout=📦 [git] Switching to branch '{branch}' in '{dest}'
+fetching=📄 [git] Fetching file '{file}' from '{repo}' branch '{branch}'
+unsupported=❌ [git] Unsupported action '{action}'
+missing_deps=❌ [git] Missing dependencies: {cmds}
+deps_ok=✅ [git] All dependencies are available
diff --git a/core/modules/git.tr.es b/core/modules/git.tr.es
new file mode 100644
index 0000000..60a68f7
--- /dev/null
+++ b/core/modules/git.tr.es
@@ -0,0 +1,7 @@
+cloning=📥 [git] Clonando repositorio '{repo}' en '{dest}'
+pulling=🔄 [git] Ejecutando git pull en '{dest}'
+checkout=📦 [git] Cambiando a rama '{branch}' en '{dest}'
+fetching=📄 [git] Extrayendo archivo '{file}' desde '{repo}' rama '{branch}'
+unsupported=❌ [git] Acción '{action}' no soportada
+missing_deps=❌ [git] Dependencias faltantes: {cmds}
+deps_ok=✅ [git] Todas las dependencias están disponibles
diff --git a/core/modules/groups.sh b/core/modules/groups.sh
new file mode 100644
index 0000000..e446b12
--- /dev/null
+++ b/core/modules/groups.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+# Module: groups
+# Description: Gestiona grupos del sistema (crear, modificar, eliminar)
+# Author: Luis GuLo
+# Version: 1.1.0
+# Dependencies: getent, groupadd, groupmod, groupdel, sudo
+
+groups_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local groupname="${args[groupname]}"
+ local gid="${args[gid]:-}"
+ local state="${args[state]:-create}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/groups.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ # 🛡️ Validación
+ if [[ "$become" != "true" && "$EUID" -ne 0 ]]; then
+ echo "${tr[priv_required]:-❌ [groups] Se requieren privilegios para gestionar grupos. Usa 'become: true'.}"
+ return 1
+ fi
+
+ if [[ -z "$groupname" ]]; then
+ echo "${tr[missing_groupname]:-❌ [groups] Falta el parámetro obligatorio 'groupname'}"
+ return 1
+ fi
+
+ case "$state" in
+ create)
+ echo "${tr[enter_create]:-🔧 [groups] Entrando en create}"
+ if getent group "$groupname" &>/dev/null; then
+ echo "$(render_msg "${tr[exists]}" "groupname=$groupname")"
+ return 0
+ fi
+ local cmd="$prefix groupadd \"$groupname\""
+ [[ -n "$gid" ]] && cmd="$cmd -g \"$gid\""
+ eval "$cmd" && echo "$(render_msg "${tr[created]}" "groupname=$groupname")"
+ ;;
+ modify)
+ echo "${tr[enter_modify]:-🔧 [groups] Entrando en modify}"
+ if ! getent group "$groupname" &>/dev/null; then
+ echo "$(render_msg "${tr[not_exists]}" "groupname=$groupname")"
+ return 1
+ fi
+ [[ -z "$gid" ]] && echo "${tr[nothing_to_modify]:-⚠️ [groups] Nada que modificar: falta 'gid'}" && return 0
+ eval "$prefix groupmod -g \"$gid\" \"$groupname\"" && echo "$(render_msg "${tr[modified]}" "groupname=$groupname")"
+ ;;
+ absent)
+ echo "${tr[enter_absent]:-🔧 [groups] Entrando en absent}"
+ if ! getent group "$groupname" &>/dev/null; then
+ echo "$(render_msg "${tr[already_deleted]}" "groupname=$groupname")"
+ return 0
+ fi
+ eval "$prefix groupdel \"$groupname\"" && echo "$(render_msg "${tr[deleted]}" "groupname=$groupname")"
+ ;;
+ *)
+ echo "$(render_msg "${tr[unsupported_state]}" "state=$state")"
+ return 1
+ ;;
+ esac
+}
+
+check_dependencies_groups() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/groups.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ local missing=()
+ for cmd in getent sudo; do
+ command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
+ done
+
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ echo "$(render_msg "${tr[missing_deps]}" "cmds=${missing[*]}")"
+ return 1
+ fi
+
+ echo "${tr[deps_ok]:-✅ [groups] Todas las dependencias están presentes}"
+ return 0
+}
diff --git a/core/modules/groups.tr.en b/core/modules/groups.tr.en
new file mode 100644
index 0000000..6df77e1
--- /dev/null
+++ b/core/modules/groups.tr.en
@@ -0,0 +1,15 @@
+priv_required=❌ [groups] Privileges required to manage groups. Use 'become: true'.
+missing_groupname=❌ [groups] Missing required parameter 'groupname'
+enter_create=🔧 [groups] Entering create
+enter_modify=🔧 [groups] Entering modify
+enter_absent=🔧 [groups] Entering absent
+exists=✅ [groups] Group '{groupname}' already exists
+not_exists=⚠️ [groups] Group '{groupname}' does not exist
+nothing_to_modify=⚠️ [groups] Nothing to modify: missing 'gid'
+created=✅ [groups] Group '{groupname}' created
+modified=✅ [groups] Group '{groupname}' modified
+deleted=✅ [groups] Group '{groupname}' deleted
+already_deleted=✅ [groups] Group '{groupname}' already deleted
+unsupported_state=❌ [groups] Unsupported state '{state}'. Use create, modify or absent.
+missing_deps=❌ [groups] Missing dependencies: {cmds}
+deps_ok=✅ [groups] All dependencies are present
diff --git a/core/modules/groups.tr.es b/core/modules/groups.tr.es
new file mode 100644
index 0000000..70e1492
--- /dev/null
+++ b/core/modules/groups.tr.es
@@ -0,0 +1,15 @@
+priv_required=❌ [groups] Se requieren privilegios para gestionar grupos. Usa 'become: true'.
+missing_groupname=❌ [groups] Falta el parámetro obligatorio 'groupname'
+enter_create=🔧 [groups] Entrando en create
+enter_modify=🔧 [groups] Entrando en modify
+enter_absent=🔧 [groups] Entrando en absent
+exists=✅ [groups] Grupo '{groupname}' ya existe
+not_exists=⚠️ [groups] Grupo '{groupname}' no existe
+nothing_to_modify=⚠️ [groups] Nada que modificar: falta 'gid'
+created=✅ [groups] Grupo '{groupname}' creado
+modified=✅ [groups] Grupo '{groupname}' modificado
+deleted=✅ [groups] Grupo '{groupname}' eliminado
+already_deleted=✅ [groups] Grupo '{groupname}' ya eliminado
+unsupported_state=❌ [groups] Estado '{state}' no soportado. Usa create, modify o absent.
+missing_deps=❌ [groups] Dependencias faltantes: {cmds}
+deps_ok=✅ [groups] Todas las dependencias están presentes
diff --git a/core/modules/lineinfile.sh b/core/modules/lineinfile.sh
new file mode 100644
index 0000000..aded42c
--- /dev/null
+++ b/core/modules/lineinfile.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# Module: lineinfile
+# Description: Asegura la presencia o reemplazo de una línea en un archivo
+# Author: Luis GuLo
+# Version: 0.2.0
+# Dependencies: grep, sed, tee, awk
+
+lineinfile_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do
+ key="${arg%%=*}"
+ value="${arg#*=}"
+ args["$key"]="$value"
+ done
+
+ local path="${args[path]}"
+ local line="${args[line]}"
+ local regexp="${args[regexp]}"
+ local insert_after="${args[insert_after]}"
+ local create="${args[create]:-true}"
+ local backup="${args[backup]:-true}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/lineinfile.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ if [[ ! -f "$path" ]]; then
+ if [[ "$create" == "true" ]]; then
+ echo "$(render_msg "${tr[creating]}" "path=$path")"
+ touch "$path"
+ else
+ echo "$(render_msg "${tr[missing_file]}" "path=$path")"
+ return 1
+ fi
+ fi
+
+ if [[ "$backup" == "true" ]]; then
+ cp "$path" "$path.bak"
+ echo "$(render_msg "${tr[backup]}" "path=$path")"
+ fi
+
+ if [[ -n "$regexp" && $(grep -Eq "$regexp" "$path" && echo "yes") == "yes" ]]; then
+ echo "$(render_msg "${tr[replacing]}" "regexp=$regexp")"
+ $prefix sed -i "s|$regexp|$line|" "$path"
+ return 0
+ fi
+
+ if [[ -n "$insert_after" && $(grep -q "$insert_after" "$path" && echo "yes") == "yes" ]]; then
+ echo "$(render_msg "${tr[inserting]}" "after=$insert_after")"
+ $prefix sed -i "/$insert_after/a $line" "$path"
+ return 0
+ fi
+
+ if grep -Fxq "$line" "$path"; then
+ echo "$(render_msg "${tr[exists]}" "line=$line")"
+ return 0
+ fi
+
+ echo "${tr[appending]:-➕ [lineinfile] Añadiendo línea al final}"
+ echo "$line" | $prefix tee -a "$path" > /dev/null
+}
+
+check_dependencies_lineinfile() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/lineinfile.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ local missing=()
+ for cmd in grep sed tee awk; do
+ command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
+ done
+
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ echo "$(render_msg "${tr[missing_deps]}" "cmds=${missing[*]}")"
+ return 1
+ fi
+
+ echo "${tr[deps_ok]:-✅ [lineinfile] Todas las dependencias están disponibles}"
+ return 0
+}
diff --git a/core/modules/lineinfile.tr.en b/core/modules/lineinfile.tr.en
new file mode 100644
index 0000000..15054eb
--- /dev/null
+++ b/core/modules/lineinfile.tr.en
@@ -0,0 +1,9 @@
+creating=📄 [lineinfile] Creating file: {path}
+missing_file=❌ [lineinfile] File does not exist and create=false
+backup=📦 Backup created: {path}.bak
+replacing=🔁 [lineinfile] Replacing line matching: {regexp}
+inserting=➕ [lineinfile] Inserting after: {after}
+exists=✅ [lineinfile] Line already present: "{line}"
+appending=➕ [lineinfile] Appending line at the end
+missing_deps=❌ [lineinfile] Missing dependencies: {cmds}
+deps_ok=✅ [lineinfile] All dependencies are available
diff --git a/core/modules/lineinfile.tr.es b/core/modules/lineinfile.tr.es
new file mode 100644
index 0000000..c28d6ca
--- /dev/null
+++ b/core/modules/lineinfile.tr.es
@@ -0,0 +1,9 @@
+creating=📄 [lineinfile] Creando archivo: {path}
+missing_file=❌ [lineinfile] El archivo no existe y create=false
+backup=📦 Copia de seguridad creada: {path}.bak
+replacing=🔁 [lineinfile] Reemplazando línea que coincide con: {regexp}
+inserting=➕ [lineinfile] Insertando después de: {after}
+exists=✅ [lineinfile] Línea ya presente: "{line}"
+appending=➕ [lineinfile] Añadiendo línea al final
+missing_deps=❌ [lineinfile] Dependencias faltantes: {cmds}
+deps_ok=✅ [lineinfile] Todas las dependencias están disponibles
diff --git a/core/modules/lookup.sh b/core/modules/lookup.sh
new file mode 100644
index 0000000..d3c93bb
--- /dev/null
+++ b/core/modules/lookup.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+# Module: lookup
+# Description: Recupera secretos cifrados del vault local
+# Author: Luis GuLo
+# Version: 1.1.0
+# Dependencies: gpg
+
+lookup_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do
+ key="${arg%%=*}"
+ value="${arg#*=}"
+ args["$key"]="$value"
+ done
+
+ local vault_key="${args[key]}"
+ local vault_dir="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}/core/vault"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/lookup.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ if [[ -f "$vault_dir/$vault_key.gpg" ]]; then
+ gpg --quiet --batch --yes --passphrase-file "$HOME/.shflow.key" -d "$vault_dir/$vault_key.gpg" 2>/dev/null || \
+ gpg --quiet --batch --yes -d "$vault_dir/$vault_key.gpg"
+ else
+ echo "$(render_msg "${tr[not_found]}" "key=$vault_key" "path=$vault_dir")"
+ return 1
+ fi
+}
+
+check_dependencies_lookup() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/lookup.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ if ! command -v gpg &> /dev/null; then
+ echo "${tr[missing_deps]:-❌ [lookup] gpg no disponible.}"
+ return 1
+ fi
+ echo "${tr[deps_ok]:-✅ [lookup] gpg disponible.}"
+ return 0
+}
diff --git a/core/modules/lookup.tr.en b/core/modules/lookup.tr.en
new file mode 100644
index 0000000..c20d4b0
--- /dev/null
+++ b/core/modules/lookup.tr.en
@@ -0,0 +1,3 @@
+not_found=❌ [lookup] Secret '{key}' not found in {path}
+missing_deps=❌ [lookup] gpg is not available.
+deps_ok=✅ [lookup] gpg is available.
diff --git a/core/modules/lookup.tr.es b/core/modules/lookup.tr.es
new file mode 100644
index 0000000..e1a89b7
--- /dev/null
+++ b/core/modules/lookup.tr.es
@@ -0,0 +1,3 @@
+not_found=❌ [lookup] Secreto '{key}' no encontrado en {path}
+missing_deps=❌ [lookup] gpg no disponible.
+deps_ok=✅ [lookup] gpg disponible.
diff --git a/core/modules/loop.sh b/core/modules/loop.sh
new file mode 100644
index 0000000..3f7cb0d
--- /dev/null
+++ b/core/modules/loop.sh
@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+# Module: loop
+# Description: Ejecuta un módulo sobre una lista o matriz de valores
+# Author: Luis GuLo
+# Version: 0.3.0
+# Dependencies: echo, tee
+
+loop_task() {
+ local host="$1"; shift
+ declare -A args
+ local items_raw="" secondary_raw="" target_module=""
+ local fail_fast="true"
+ declare -A module_args
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/loop.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ # Parsear argumentos
+ for arg in "$@"; do
+ key="${arg%%=*}"
+ value="${arg#*=}"
+ case "$key" in
+ items) items_raw="$value" ;;
+ secondary) secondary_raw="$value" ;;
+ module) target_module="$value" ;;
+ fail_fast) fail_fast="$value" ;;
+ *) module_args["$key"]="$value" ;;
+ esac
+ done
+
+ if [[ -z "$items_raw" || -z "$target_module" ]]; then
+ echo "${tr[missing_args]:-❌ [loop] Faltan argumentos obligatorios: items=... module=...}"
+ return 1
+ fi
+
+ IFS=',' read -r -a items <<< "$items_raw"
+ IFS=',' read -r -a secondary <<< "$secondary_raw"
+
+ for item in "${items[@]}"; do
+ if [[ "$item" == *:* ]]; then
+ item_key="${item%%:*}"
+ item_value="${item#*:}"
+ else
+ item_key="$item"
+ item_value=""
+ fi
+
+ if [[ -n "$secondary_raw" ]]; then
+ for sec in "${secondary[@]}"; do
+ run_module "$host" "$target_module" "$item" "$item_key" "$item_value" "$sec" module_args || {
+ echo "$(render_msg "${tr[fail_secondary]}" "item=$item" "secondary=$sec")"
+ [[ "$fail_fast" == "true" ]] && return 1
+ }
+ done
+ else
+ run_module "$host" "$target_module" "$item" "$item_key" "$item_value" "" module_args || {
+ echo "$(render_msg "${tr[fail_item]}" "item=$item")"
+ [[ "$fail_fast" == "true" ]] && return 1
+ }
+ fi
+ done
+}
+
+run_module() {
+ local host="$1"
+ local module="$2"
+ local item="$3"
+ local item_key="$4"
+ local item_value="$5"
+ local secondary_item="$6"
+ declare -n args_ref="$7"
+
+ local call_args=()
+ for key in "${!args_ref[@]}"; do
+ value="${args_ref[$key]}"
+ value="${value//\{\{item\}\}/$item}"
+ value="${value//\{\{item_key\}\}/$item_key}"
+ value="${value//\{\{item_value\}\}/$item_value}"
+ value="${value//\{\{secondary_item\}\}/$secondary_item}"
+ call_args+=("$key=$value")
+ done
+
+ echo "🔁 [loop] → $module con item='$item' secondary='$secondary_item'"
+
+ local PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
+ local MODULE_PATH=""
+ local SEARCH_PATHS=("$PROJECT_ROOT/core/modules" "$PROJECT_ROOT/user_modules" "$PROJECT_ROOT/community_modules")
+ for search_dir in "${SEARCH_PATHS[@]}"; do
+ while IFS= read -r -d '' candidate; do
+ [[ "$(basename "$candidate")" == "${module}.sh" ]] && MODULE_PATH="$candidate" && break 2
+ done < <(find "$search_dir" -type f -name "${module}.sh" -print0)
+ done
+
+ if [[ -z "$MODULE_PATH" ]]; then
+ echo "$(render_msg "${tr[module_not_found]}" "module=$module")"
+ return 1
+ fi
+
+ source "$MODULE_PATH"
+ ! declare -f "${module}_task" > /dev/null && echo "$(render_msg "${tr[task_not_found]}" "module=$module")" && return 1
+
+ "${module}_task" "$host" "${call_args[@]}"
+}
+
+check_dependencies_loop() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/loop.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ local missing=()
+ for cmd in echo tee; do
+ command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
+ done
+
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ echo "$(render_msg "${tr[missing_deps]}" "cmds=${missing[*]}")"
+ return 1
+ fi
+
+ echo "${tr[deps_ok]:-✅ [loop] Dependencias disponibles.}"
+ return 0
+}
diff --git a/core/modules/loop.tr.en b/core/modules/loop.tr.en
new file mode 100644
index 0000000..d0de2dd
--- /dev/null
+++ b/core/modules/loop.tr.en
@@ -0,0 +1,7 @@
+missing_args=❌ [loop] Missing required arguments: items=... module=...
+fail_item=⚠️ [loop] Iteration failed with '{item}'
+fail_secondary=⚠️ [loop] Iteration failed with '{item}' and '{secondary}'
+module_not_found=❌ [loop] Module '{module}' not found
+task_not_found=❌ [loop] Function '{module}_task' not found
+missing_deps=❌ [loop] Missing dependencies: {cmds}
+deps_ok=✅ [loop] Dependencies available.
diff --git a/core/modules/loop.tr.es b/core/modules/loop.tr.es
new file mode 100644
index 0000000..aaf85ec
--- /dev/null
+++ b/core/modules/loop.tr.es
@@ -0,0 +1,7 @@
+missing_args=❌ [loop] Faltan argumentos obligatorios: items=... module=...
+fail_item=⚠️ [loop] Falló la iteración con '{item}'
+fail_secondary=⚠️ [loop] Falló la iteración con '{item}' y '{secondary}'
+module_not_found=❌ [loop] Módulo '{module}' no encontrado
+task_not_found=❌ [loop] Función '{module}_task' no encontrada
+missing_deps=❌ [loop] Dependencias faltantes: {cmds}
+deps_ok=✅ [loop] Dependencias disponibles.
diff --git a/core/modules/openssl.sh b/core/modules/openssl.sh
new file mode 100644
index 0000000..d872dca
--- /dev/null
+++ b/core/modules/openssl.sh
@@ -0,0 +1,141 @@
+#!/bin/bash
+# Module: openssl
+# Description: Gestiona certificados y claves con OpenSSL (convertir, inspeccionar, instalar como CA)
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.1.0
+# Dependencies: openssl, sudo, bash
+
+openssl_task() {
+ local host="$1"; shift
+ check_dependencies_openssl || return 1
+
+ local state="" src="" dest="" format="" password="" alias="" trust_path="" become="false"
+ for arg in "$@"; do
+ case "$arg" in
+ state=*) state="${arg#state=}" ;;
+ src=*) src="${arg#src=}" ;;
+ dest=*) dest="${arg#dest=}" ;;
+ format=*) format="${arg#format=}" ;;
+ password=*) password="${arg#password=}" ;;
+ alias=*) alias="${arg#alias=}" ;;
+ trust_path=*) trust_path="${arg#trust_path=}" ;;
+ become=*) become="${arg#become=}" ;;
+ esac
+ done
+
+ local sudo_cmd=""
+ [[ "$become" == "true" ]] && sudo_cmd="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/openssl.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ case "$state" in
+ convert)
+ if [[ -z "$src" || -z "$dest" || -z "$format" ]]; then
+ echo "${tr[missing_convert]:-❌ [openssl] Faltan argumentos para conversión: src, dest, format}"
+ return 1
+ fi
+ if [[ ! -f "$src" ]]; then
+ echo "$(render_msg "${tr[src_not_found]}" "src=$src")"
+ return 1
+ fi
+ echo "$(render_msg "${tr[converting]}" "src=$src" "format=$format")"
+
+ case "$format" in
+ pem)
+ $sudo_cmd openssl pkcs12 -in "$src" -out "$dest" -nodes -password pass:"$password" && \
+ echo "$(render_msg "${tr[converted]}" "dest=$dest")"
+ ;;
+ pfx)
+ $sudo_cmd openssl pkcs12 -export -out "$dest" -inkey "$src" -in "$src" -password pass:"$password" && \
+ echo "$(render_msg "${tr[converted]}" "dest=$dest")"
+ ;;
+ key)
+ $sudo_cmd openssl pkey -in "$src" -out "$dest" && \
+ echo "$(render_msg "${tr[key_extracted]}" "dest=$dest")"
+ ;;
+ cer)
+ $sudo_cmd openssl x509 -in "$src" -out "$dest" -outform DER && \
+ echo "$(render_msg "${tr[cer_converted]}" "dest=$dest")"
+ ;;
+ *)
+ echo "$(render_msg "${tr[unsupported_format]}" "format=$format")"
+ return 1
+ ;;
+ esac
+ ;;
+
+ inspect)
+ if [[ -z "$src" || ! -f "$src" ]]; then
+ echo "$(render_msg "${tr[missing_inspect]}" "src=$src")"
+ return 1
+ fi
+ echo "$(render_msg "${tr[inspecting]}" "src=$src")"
+ $sudo_cmd openssl x509 -in "$src" -noout -text | grep -E 'Subject:|Issuer:|Not Before:|Not After :|Fingerprint' || echo "${tr[inspect_fail]:-⚠️ [openssl] No se pudo extraer información}"
+ ;;
+
+ trust)
+ if [[ -z "$src" || -z "$alias" || -z "$trust_path" ]]; then
+ echo "${tr[missing_trust]:-❌ [openssl] Faltan argumentos para instalación como CA: src, alias, trust_path}"
+ return 1
+ fi
+ if [[ ! -f "$src" ]]; then
+ echo "$(render_msg "${tr[src_not_found]}" "src=$src")"
+ return 1
+ fi
+ echo "$(render_msg "${tr[trusting]}" "alias=$alias")"
+ $sudo_cmd cp "$src" "$trust_path/$alias.crt" && \
+ $sudo_cmd update-ca-certificates && \
+ echo "${tr[trusted]:-✅ [openssl] Certificado instalado y CA actualizada}"
+ ;;
+
+ untrust)
+ if [[ -z "$alias" || -z "$trust_path" ]]; then
+ echo "${tr[missing_untrust]:-❌ [openssl] Faltan argumentos para eliminación: alias, trust_path}"
+ return 1
+ fi
+ local cert_path="$trust_path/$alias.crt"
+ if [[ ! -f "$cert_path" ]]; then
+ echo "$(render_msg "${tr[untrust_not_found]}" "alias=$alias" "trust_path=$trust_path")"
+ return 0
+ fi
+ echo "$(render_msg "${tr[untrusting]}" "alias=$alias")"
+ $sudo_cmd rm -f "$cert_path" && \
+ $sudo_cmd update-ca-certificates && \
+ echo "${tr[untrusted]:-✅ [openssl] Certificado eliminado y CA actualizada}"
+ ;;
+
+ *)
+ echo "$(render_msg "${tr[unknown_state]}" "state=$state")"
+ return 1
+ ;;
+ esac
+}
+
+check_dependencies_openssl() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/openssl.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ local missing=()
+ for cmd in openssl sudo; do
+ command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
+ done
+
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ echo "$(render_msg "${tr[missing_deps]}" "cmds=${missing[*]}")"
+ return 1
+ fi
+
+ echo "${tr[deps_ok]:-✅ [openssl] Todas las dependencias están disponibles}"
+ return 0
+}
diff --git a/core/modules/openssl.tr.en b/core/modules/openssl.tr.en
new file mode 100644
index 0000000..a0caffb
--- /dev/null
+++ b/core/modules/openssl.tr.en
@@ -0,0 +1,20 @@
+missing_convert=❌ [openssl] Missing arguments for conversion: src, dest, format
+missing_inspect=❌ [openssl] File not found for inspection: {src}
+missing_trust=❌ [openssl] Missing arguments for CA installation: src, alias, trust_path
+missing_untrust=❌ [openssl] Missing arguments for removal: alias, trust_path
+src_not_found=❌ [openssl] Input file not found: {src}
+converting=🔐 [openssl] Converting {src} to format {format}...
+converted=✅ [openssl] Conversion completed: {dest}
+key_extracted=✅ [openssl] Key extracted: {dest}
+cer_converted=✅ [openssl] Certificate converted to .cer: {dest}
+unsupported_format=❌ [openssl] Unsupported destination format: {format}
+inspecting=🔍 [openssl] Inspecting certificate: {src}
+inspect_fail=⚠️ [openssl] Failed to extract certificate information
+trusting=🏛️ [openssl] Installing certificate '{alias}' as trusted CA...
+trusted=✅ [openssl] Certificate installed and CA updated
+untrust_not_found=⚠️ [openssl] Certificate '{alias}' not found in {trust_path}
+untrusting=🧹 [openssl] Removing certificate '{alias}' from trusted CAs...
+untrusted=✅ [openssl] Certificate removed and CA updated
+unknown_state=❌ [openssl] Unknown state: '{state}'. Use 'convert', 'inspect', 'trust' or 'untrust'
+missing_deps=❌ [openssl] Missing dependencies: {cmds}
+deps_ok=✅ [openssl] All dependencies are available
diff --git a/core/modules/openssl.tr.es b/core/modules/openssl.tr.es
new file mode 100644
index 0000000..0b86cc0
--- /dev/null
+++ b/core/modules/openssl.tr.es
@@ -0,0 +1,20 @@
+missing_convert=❌ [openssl] Faltan argumentos para conversión: src, dest, format
+missing_inspect=❌ [openssl] Archivo no encontrado para inspección: {src}
+missing_trust=❌ [openssl] Faltan argumentos para instalación como CA: src, alias, trust_path
+missing_untrust=❌ [openssl] Faltan argumentos para eliminación: alias, trust_path
+src_not_found=❌ [openssl] Archivo no encontrado: {src}
+converting=🔐 [openssl] Convirtiendo {src} a formato {format}...
+converted=✅ [openssl] Conversión completada: {dest}
+key_extracted=✅ [openssl] Clave extraída: {dest}
+cer_converted=✅ [openssl] Certificado convertido a .cer: {dest}
+unsupported_format=❌ [openssl] Formato de destino no soportado: {format}
+inspecting=🔍 [openssl] Inspeccionando certificado: {src}
+inspect_fail=⚠️ [openssl] No se pudo extraer información
+trusting=🏛️ [openssl] Instalando certificado '{alias}' como CA...
+trusted=✅ [openssl] Certificado instalado y CA actualizada
+untrust_not_found=⚠️ [openssl] Certificado '{alias}' no encontrado en {trust_path}
+untrusting=🧹 [openssl] Eliminando certificado '{alias}' de CA confiables...
+untrusted=✅ [openssl] Certificado eliminado y CA actualizada
+unknown_state=❌ [openssl] Estado desconocido: '{state}'. Usa 'convert', 'inspect', 'trust' o 'untrust'
+missing_deps=❌ [openssl] Dependencias faltantes: {cmds}
+deps_ok=✅ [openssl] Todas las dependencias están disponibles
diff --git a/core/modules/package.sh b/core/modules/package.sh
new file mode 100644
index 0000000..7e41161
--- /dev/null
+++ b/core/modules/package.sh
@@ -0,0 +1,132 @@
+#!/bin/bash
+# Module: package
+# Description: Instala, actualiza o elimina paquetes .deb/.rpm y permite actualizar el sistema
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 2.2.0
+# Dependencies: ssh
+
+package_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do
+ key="${arg%%=*}"
+ value="${arg#*=}"
+ args["$key"]="$value"
+ done
+
+ local name="${args[name]:-}"
+ local state="${args[state]:-present}"
+ local become="${args[become]:-false}"
+ local update_type="${args[update_type]:-full}" # full | security
+
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/package.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r key val; do tr["$key"]="$val"; done < "$trfile"
+ fi
+
+ echo "$(render_msg "${tr[start]}" "state=$state" "name=${name:-<sistema>}")"
+
+ local pkg_mgr
+ pkg_mgr=$(ssh "$host" "command -v apt-get || command -v apt || command -v dnf || command -v yum")
+
+ if [ -z "$pkg_mgr" ]; then
+ echo "${tr[no_pkg_mgr]:-❌ [package] No se detectó gestor de paquetes compatible en el host.}"
+ return 1
+ fi
+
+ case "$pkg_mgr" in
+ *apt*)
+ if [ "$state" = "system-update" ]; then
+ system_update_apt "$host" "$prefix"
+ else
+ package_apt "$host" "$name" "$state" "$prefix"
+ fi
+ ;;
+ *yum*|*dnf*)
+ if [ "$state" = "system-update" ]; then
+ system_update_rpm "$host" "$prefix" "$update_type"
+ else
+ package_rpm "$host" "$name" "$state" "$prefix"
+ fi
+ ;;
+ *)
+ echo "$(render_msg "${tr[unsupported_mgr]}" "mgr=$pkg_mgr")"
+ return 1
+ ;;
+ esac
+}
+
+package_apt() {
+ local host="$1"
+ local name="$2"
+ local state="$3"
+ local prefix="$4"
+
+ local check_cmd="dpkg -s '$name' &> /dev/null"
+ local install_cmd="$prefix apt-get update && $prefix apt-get install -y '$name'"
+ local remove_cmd="$prefix apt-get remove -y '$name'"
+ local upgrade_cmd="$prefix apt-get update && $prefix apt-get install --only-upgrade -y '$name'"
+
+ case "$state" in
+ present) ssh "$host" "$check_cmd || $install_cmd" ;;
+ absent) ssh "$host" "$check_cmd && $remove_cmd" ;;
+ latest) ssh "$host" "$check_cmd && $upgrade_cmd || $install_cmd" ;;
+ *) echo "$(render_msg "${tr[unsupported_state_apt]}" "state=$state")"; return 1 ;;
+ esac
+}
+
+package_rpm() {
+ local host="$1"
+ local name="$2"
+ local state="$3"
+ local prefix="$4"
+
+ local check_cmd="rpm -q '$name' &> /dev/null"
+ local install_cmd="$prefix yum install -y '$name' || $prefix dnf install -y '$name'"
+ local remove_cmd="$prefix yum remove -y '$name' || $prefix dnf remove -y '$name'"
+ local upgrade_cmd="$prefix yum update -y '$name' || $prefix dnf upgrade -y '$name'"
+
+ case "$state" in
+ present) ssh "$host" "$check_cmd || $install_cmd" ;;
+ absent) ssh "$host" "$check_cmd && $remove_cmd" ;;
+ latest) ssh "$host" "$check_cmd && $upgrade_cmd || $install_cmd" ;;
+ *) echo "$(render_msg "${tr[unsupported_state_rpm]}" "state=$state")"; return 1 ;;
+ esac
+}
+
+system_update_apt() {
+ local host="$1"
+ local prefix="$2"
+ echo "${tr[update_apt]:-🔄 [package] Actualización completa del sistema (.deb)}"
+ ssh "$host" "$prefix apt-get update && $prefix apt-get upgrade -y"
+}
+
+system_update_rpm() {
+ local host="$1"
+ local prefix="$2"
+ local update_type="$3"
+
+ if [ "$update_type" = "security" ]; then
+ echo "${tr[update_rpm_security]:-🔐 [package] Actualización de seguridad (.rpm)}"
+ ssh "$host" "$prefix dnf update --security -y || $prefix yum update --security -y"
+ else
+ echo "${tr[update_rpm_full]:-🔄 [package] Actualización completa del sistema (.rpm)}"
+ ssh "$host" "$prefix dnf upgrade --refresh -y || $prefix yum update -y"
+ fi
+}
+
+check_dependencies_package() {
+ if ! command -v ssh &> /dev/null; then
+ echo "${tr[missing_deps]:-❌ [package] ssh no está disponible.}"
+ return 1
+ fi
+ echo "${tr[deps_ok]:-✅ [package] ssh disponible.}"
+ return 0
+}
diff --git a/core/modules/package.tr.en b/core/modules/package.tr.en
new file mode 100644
index 0000000..baae9c5
--- /dev/null
+++ b/core/modules/package.tr.en
@@ -0,0 +1,10 @@
+start=📦 [package] State: {state} | Package: {name} | Manager: detecting...
+no_pkg_mgr=❌ [package] No compatible package manager detected on host.
+unsupported_mgr=❌ [package] Manager '{mgr}' not supported.
+unsupported_state_apt=❌ [package] State '{state}' not supported for APT.
+unsupported_state_rpm=❌ [package] State '{state}' not supported for RPM.
+update_apt=🔄 [package] Full system update (.deb)
+update_rpm_security=🔐 [package] Security update (.rpm)
+update_rpm_full=🔄 [package] Full system update (.rpm)
+missing_deps=❌ [package] ssh is not available.
+deps_ok=✅ [package] ssh is available.
diff --git a/core/modules/package.tr.es b/core/modules/package.tr.es
new file mode 100644
index 0000000..ed7c4fa
--- /dev/null
+++ b/core/modules/package.tr.es
@@ -0,0 +1,10 @@
+start=📦 [package] Estado: {state} | Paquete: {name} | Gestor: detectando...
+no_pkg_mgr=❌ [package] No se detectó gestor de paquetes compatible en el host.
+unsupported_mgr=❌ [package] Gestor '{mgr}' no soportado.
+unsupported_state_apt=❌ [package] Estado '{state}' no soportado para APT.
+unsupported_state_rpm=❌ [package] Estado '{state}' no soportado para RPM.
+update_apt=🔄 [package] Actualización completa del sistema (.deb)
+update_rpm_security=🔐 [package] Actualización de seguridad (.rpm)
+update_rpm_full=🔄 [package] Actualización completa del sistema (.rpm)
+missing_deps=❌ [package] ssh no está disponible.
+deps_ok=✅ [package] ssh disponible.
diff --git a/core/modules/ping.sh b/core/modules/ping.sh
new file mode 100644
index 0000000..79b72c6
--- /dev/null
+++ b/core/modules/ping.sh
@@ -0,0 +1,55 @@
+#!/bin/bash
+# Module: ping
+# Description: Verifica conectividad desde el host remoto hacia un destino específico
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.2
+# Dependencies: ping, ssh
+
+ping_task() {
+ local host="$1"; shift
+ declare -A args; for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local count="${args[count]:-2}"
+ local timeout="${args[timeout]:-3}"
+ local target="${args[target]:-127.0.0.1}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/ping.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r key val; do tr["$key"]="$val"; done < "$trfile"
+ else
+ echo "⚠️ [ping] Archivo de traducción no encontrado: $trfile"
+ fi
+
+ echo "$(render_msg "${tr[start]}" "host=$host" "target=$target")"
+
+ if ssh "$host" "$prefix ping -c $count -W $timeout $target &>/dev/null"; then
+ echo " $(render_msg "${tr[success]}" "host=$host" "target=$target")"
+ return 0
+ else
+ echo " $(render_msg "${tr[fail]}" "host=$host" "target=$target")"
+ return 1
+ fi
+}
+
+check_dependencies_ping() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/ping.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r key val; do tr["$key"]="$val"; done < "$trfile"
+ fi
+
+ if ! command -v ssh &> /dev/null || ! command -v ping &> /dev/null; then
+ echo " ${tr[missing_deps]:-❌ [ping] ssh o ping no están disponibles.}"
+ return 1
+ fi
+ echo " ${tr[deps_ok]:-✅ [ping] ssh y ping disponibles.}"
+ return 0
+}
diff --git a/core/modules/ping.tr.en b/core/modules/ping.tr.en
new file mode 100644
index 0000000..1faf4fe
--- /dev/null
+++ b/core/modules/ping.tr.en
@@ -0,0 +1,5 @@
+start=📡 [ping] Testing connectivity from {host} to {target}...
+success=✅ [ping] {host} can reach {target}
+fail=❌ [ping] {host} cannot reach {target}
+missing_deps=❌ [ping] ssh or ping are not available.
+deps_ok=✅ [ping] ssh and ping are available.
diff --git a/core/modules/ping.tr.es b/core/modules/ping.tr.es
new file mode 100644
index 0000000..37c3805
--- /dev/null
+++ b/core/modules/ping.tr.es
@@ -0,0 +1,5 @@
+start=📡 [ping] Probando conectividad desde {host} hacia {target}...
+success=✅ [ping] {host} puede alcanzar {target}
+fail=❌ [ping] {host} no puede alcanzar {target}
+missing_deps=❌ [ping] ssh o ping no están disponibles.
+deps_ok=✅ [ping] ssh y ping disponibles.
diff --git a/core/modules/replace.sh b/core/modules/replace.sh
new file mode 100644
index 0000000..fb961ea
--- /dev/null
+++ b/core/modules/replace.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# Module: replace
+# Description: Reemplaza texto en archivos usando expresiones regulares
+# Author: Luis GuLo
+# Version: 0.2.0
+# Dependencies: sed, cp, tee
+
+replace_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do
+ key="${arg%%=*}"
+ value="${arg#*=}"
+ args["$key"]="$value"
+ done
+
+ local path="${args[path]}"
+ local regexp="${args[regexp]}"
+ local replace="${args[replace]}"
+ local backup="${args[backup]:-true}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/replace.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ if [[ ! -f "$path" ]]; then
+ echo "$(render_msg "${tr[missing_file]}" "path=$path")"
+ return 1
+ fi
+
+ if [[ "$backup" == "true" ]]; then
+ cp "$path" "$path.bak"
+ echo "$(render_msg "${tr[backup_created]}" "path=$path")"
+ fi
+
+ $prefix sed -i "s|$regexp|$replace|g" "$path"
+ echo "$(render_msg "${tr[replaced]}" "path=$path")"
+}
+
+check_dependencies_replace() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/replace.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ local missing=()
+ for cmd in sed cp tee; do
+ command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
+ done
+
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ echo "$(render_msg "${tr[missing_deps]}" "cmds=${missing[*]}")"
+ return 1
+ fi
+
+ echo "${tr[deps_ok]:-✅ [replace] Todas las dependencias están disponibles}"
+ return 0
+}
diff --git a/core/modules/replace.tr.en b/core/modules/replace.tr.en
new file mode 100644
index 0000000..f4ec4ae
--- /dev/null
+++ b/core/modules/replace.tr.en
@@ -0,0 +1,5 @@
+missing_file=❌ [replace] File not found: {path}
+backup_created=📦 Backup created: {path}.bak
+replaced=✅ [replace] Replacement applied to: {path}
+missing_deps=❌ [replace] Missing dependencies: {cmds}
+deps_ok=✅ [replace] All dependencies are available
diff --git a/core/modules/replace.tr.es b/core/modules/replace.tr.es
new file mode 100644
index 0000000..f6e647e
--- /dev/null
+++ b/core/modules/replace.tr.es
@@ -0,0 +1,5 @@
+missing_file=❌ [replace] El archivo no existe: {path}
+backup_created=📦 Copia de seguridad creada: {path}.bak
+replaced=✅ [replace] Reemplazo aplicado en: {path}
+missing_deps=❌ [replace] Dependencias faltantes: {cmds}
+deps_ok=✅ [replace] Todas las dependencias están disponibles
diff --git a/core/modules/run.sh b/core/modules/run.sh
new file mode 100644
index 0000000..4ec4e9b
--- /dev/null
+++ b/core/modules/run.sh
@@ -0,0 +1,90 @@
+#!/bin/bash
+# Module: run
+# Description: Ejecuta comandos remotos vía SSH, con soporte para vault y sudo
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 2.0.0
+# Dependencies: ssh, core/utils/vault_utils.sh
+
+# Detectar raíz del proyecto si no está definida
+PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
+
+# Cargar utilidades
+source "$PROJECT_ROOT/core/utils/vault_utils.sh"
+
+run_task() {
+ local host="$1"; shift
+ declare -A args
+
+ while [[ "$#" -gt 0 ]]; do
+ case "$1" in
+ *=*)
+ key="${1%%=*}"
+ value="${1#*=}"
+ args["$key"]="$value"
+ ;;
+ esac
+ shift
+ done
+
+ local command="${args[command]}"
+ local become="${args[become]:-}"
+ local vault_key="${args[vault_key]:-}"
+
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/run.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$val"; done < "$trfile"
+ fi
+
+ # 🧠 Comandos que no deben ejecutarse con sudo
+ local safe_cmds=("echo" "true" "false" "command" "which" "exit" "test")
+ local first_cmd="${command%% *}"
+ for safe in "${safe_cmds[@]}"; do
+ if [[ "$first_cmd" == "$safe" ]]; then
+ prefix=""
+ break
+ fi
+ done
+
+ # 🔁 Interpolación de variables ShFlow
+ for var in $(compgen -A variable | grep '^shflow_vars_'); do
+ key="${var#shflow_vars_}"
+ value="${!var}"
+ command="${command//\{\{ $key \}\}/$value}"
+ done
+
+ echo "$(render_msg "${tr[start]}" "host=$host" "command=$command" "prefix=$prefix")"
+
+ if [ -n "$vault_key" ]; then
+ local secret
+ secret=$(get_secret "$vault_key") || {
+ echo "$(render_msg "${tr[vault_fail]}" "vault_key=$vault_key")"
+ return 1
+ }
+ ssh "$host" "$prefix TOKEN='$secret' $command"
+ else
+ ssh "$host" "$prefix $command"
+ fi
+}
+
+check_dependencies_run() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/run.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ if ! command -v ssh &> /dev/null; then
+ echo "${tr[missing_deps]:-❌ [run] ssh no está disponible.}"
+ return 1
+ fi
+ echo "${tr[deps_ok]:-✅ [run] ssh disponible.}"
+ return 0
+}
diff --git a/core/modules/run.tr.en b/core/modules/run.tr.en
new file mode 100644
index 0000000..55a3f3e
--- /dev/null
+++ b/core/modules/run.tr.en
@@ -0,0 +1,4 @@
+start=📡 [run] Executing on {host}: {prefix} {command}
+vault_fail=❌ [run] Failed to retrieve secret '{vault_key}'
+missing_deps=❌ [run] ssh is not available.
+deps_ok=✅ [run] ssh is available.
diff --git a/core/modules/run.tr.es b/core/modules/run.tr.es
new file mode 100644
index 0000000..20b7f2f
--- /dev/null
+++ b/core/modules/run.tr.es
@@ -0,0 +1,4 @@
+start=📡 [run] Ejecutando en {host}: {prefix} {command}
+vault_fail=❌ [run] No se pudo obtener el secreto '{vault_key}'
+missing_deps=❌ [run] ssh no está disponible.
+deps_ok=✅ [run] ssh disponible.
diff --git a/core/modules/service.sh b/core/modules/service.sh
new file mode 100644
index 0000000..bda0cd3
--- /dev/null
+++ b/core/modules/service.sh
@@ -0,0 +1,64 @@
+#!/bin/bash
+# Module: service
+# Description: Controla servicios del sistema remoto (start, stop, restart, enable, disable) con idempotencia
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.2.0
+# Dependencies: ssh, systemctl
+
+service_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do
+ key="${arg%%=*}"
+ value="${arg#*=}"
+ args["$key"]="$value"
+ done
+
+ local name="${args[name]}"
+ local state="${args[state]}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/service.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ case "$state" in
+ start|stop|restart|enable|disable)
+ echo "$(render_msg "${tr[executing]}" "state=$state" "name=$name" "host=$host")"
+ ssh "$host" "$prefix systemctl $state '$name'"
+ ;;
+ *)
+ echo "$(render_msg "${tr[unsupported]}" "state=$state")"
+ return 1
+ ;;
+ esac
+}
+
+check_dependencies_service() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/service.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ if ! command -v ssh &> /dev/null; then
+ echo "${tr[missing_ssh]:-❌ [service] ssh no está disponible.}"
+ return 1
+ fi
+ echo "${tr[ssh_ok]:-✅ [service] ssh disponible.}"
+
+ if ! command -v systemctl &> /dev/null; then
+ echo "${tr[missing_systemctl]:-⚠️ [service] systemctl no está disponible localmente. Se asumirá que existe en el host remoto.}"
+ else
+ echo "${tr[systemctl_ok]:-✅ [service] systemctl disponible localmente.}"
+ fi
+ return 0
+}
diff --git a/core/modules/service.tr.en b/core/modules/service.tr.en
new file mode 100644
index 0000000..87dc131
--- /dev/null
+++ b/core/modules/service.tr.en
@@ -0,0 +1,6 @@
+executing=🔧 [service] Executing '{state}' on service '{name}' at {host}
+unsupported=❌ [service] State '{state}' not supported. Use start, stop, restart, enable or disable.
+missing_ssh=❌ [service] ssh is not available.
+ssh_ok=✅ [service] ssh is available.
+missing_systemctl=⚠️ [service] systemctl is not available locally. Assuming it exists on the remote host.
+systemctl_ok=✅ [service] systemctl is available locally.
diff --git a/core/modules/service.tr.es b/core/modules/service.tr.es
new file mode 100644
index 0000000..26c4032
--- /dev/null
+++ b/core/modules/service.tr.es
@@ -0,0 +1,6 @@
+executing=🔧 [service] Ejecutando '{state}' sobre el servicio '{name}' en {host}
+unsupported=❌ [service] Estado '{state}' no soportado. Usa start, stop, restart, enable o disable.
+missing_ssh=❌ [service] ssh no está disponible.
+ssh_ok=✅ [service] ssh disponible.
+missing_systemctl=⚠️ [service] systemctl no está disponible localmente. Se asumirá que existe en el host remoto.
+systemctl_ok=✅ [service] systemctl disponible localmente.
diff --git a/core/modules/smtp_send.sh b/core/modules/smtp_send.sh
new file mode 100644
index 0000000..6961ec4
--- /dev/null
+++ b/core/modules/smtp_send.sh
@@ -0,0 +1,94 @@
+#!/bin/bash
+# Module: smtp_send
+# Description: Envía un correo de prueba usando SMTP con netcat o openssl s_client
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 0.2.0
+# Dependencies: nc o openssl, base64
+
+smtp_send_task() {
+ declare -A args
+ for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local smtp_server="${args[smtp_server]}"
+ local smtp_port="${args[smtp_port]:-587}"
+ local smtp_user="${args[smtp_user]}"
+ local smtp_pass="${args[smtp_pass]}"
+ local from="${args[from]}"
+ local to="${args[to]}"
+ local subject="${args[subject]:-Prueba desde ShFlow}"
+ local body="${args[body]:-Este es un correo de prueba enviado desde el módulo smtp_send.}"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/smtp_send.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ if [[ -z "$smtp_server" || -z "$smtp_user" || -z "$smtp_pass" || -z "$from" || -z "$to" ]]; then
+ echo "${tr[missing_args]:-❌ [smtp_send] Faltan argumentos obligatorios: smtp_server, smtp_user, smtp_pass, from, to}"
+ return 1
+ fi
+
+ echo "$(render_msg "${tr[start]}" "to=$to" "server=$smtp_server" "port=$smtp_port")"
+
+ local auth_user auth_pass
+ auth_user=$(echo -n "$smtp_user" | base64)
+ auth_pass=$(echo -n "$smtp_pass" | base64)
+
+ local smtp_script
+ smtp_script=$(cat <<EOF
+EHLO localhost
+AUTH LOGIN
+$auth_user
+$auth_pass
+MAIL FROM:<$from>
+RCPT TO:<$to>
+DATA
+Subject: $subject
+From: $from
+To: $to
+
+$body
+.
+QUIT
+EOF
+)
+
+ if command -v nc &>/dev/null; then
+ echo "${tr[using_nc]:-🔧 Usando netcat para conexión SMTP...}"
+ echo "$smtp_script" | nc "$smtp_server" "$smtp_port"
+ elif command -v openssl &>/dev/null; then
+ echo "${tr[using_openssl]:-🔧 Usando openssl para conexión STARTTLS...}"
+ echo "$smtp_script" | openssl s_client -starttls smtp -crlf -connect "$smtp_server:$smtp_port" 2>/dev/null
+ else
+ echo "${tr[missing_tools]:-❌ [smtp_send] No se encontró ni netcat (nc) ni openssl en el sistema.}"
+ return 1
+ fi
+
+ echo "${tr[done]:-✅ [smtp_send] Comando ejecutado. Verifica si el correo fue recibido.}"
+}
+
+check_dependencies_smtp_send() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/smtp_send.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ if command -v nc &>/dev/null || command -v openssl &>/dev/null; then
+ if ! command -v base64 &>/dev/null; then
+ echo "${tr[missing_base64]:-❌ [smtp_send] Falta base64 en el sistema.}"
+ return 1
+ fi
+ local tool=$(command -v nc &>/dev/null && echo "nc" || echo "openssl")
+ echo "$(render_msg "${tr[deps_ok]}" "tool=$tool")"
+ return 0
+ else
+ echo "${tr[missing_tools]:-❌ [smtp_send] No se encontró ni nc ni openssl.}"
+ return 1
+ fi
+}
diff --git a/core/modules/smtp_send.tr.en b/core/modules/smtp_send.tr.en
new file mode 100644
index 0000000..f99ba99
--- /dev/null
+++ b/core/modules/smtp_send.tr.en
@@ -0,0 +1,8 @@
+missing_args=❌ [smtp_send] Missing required arguments: smtp_server, smtp_user, smtp_pass, from, to
+start=📡 [smtp_send] Preparing to send to {to} via {server}:{port}...
+using_nc=🔧 Using netcat for SMTP connection...
+using_openssl=🔧 Using openssl for STARTTLS connection...
+missing_tools=❌ [smtp_send] Neither netcat (nc) nor openssl found on system.
+missing_base64=❌ [smtp_send] base64 is missing on the system.
+deps_ok=✅ [smtp_send] Tools available: {tool}, base64
+done=✅ [smtp_send] Command executed. Check if the email was received.
diff --git a/core/modules/smtp_send.tr.es b/core/modules/smtp_send.tr.es
new file mode 100644
index 0000000..4fa0b99
--- /dev/null
+++ b/core/modules/smtp_send.tr.es
@@ -0,0 +1,8 @@
+missing_args=❌ [smtp_send] Faltan argumentos obligatorios: smtp_server, smtp_user, smtp_pass, from, to
+start=📡 [smtp_send] Preparando envío a {to} vía {server}:{port}...
+using_nc=🔧 Usando netcat para conexión SMTP...
+using_openssl=🔧 Usando openssl para conexión STARTTLS...
+missing_tools=❌ [smtp_send] No se encontró ni netcat (nc) ni openssl en el sistema.
+missing_base64=❌ [smtp_send] Falta base64 en el sistema.
+deps_ok=✅ [smtp_send] Herramientas disponibles: {tool}, base64
+done=✅ [smtp_send] Comando ejecutado. Verifica si el correo fue recibido.
diff --git a/core/modules/template.sh b/core/modules/template.sh
new file mode 100644
index 0000000..887a47b
--- /dev/null
+++ b/core/modules/template.sh
@@ -0,0 +1,131 @@
+#!/usr/bin/env bash
+# Module: template
+# Description: Genera archivos a partir de plantillas con variables {{var}}, bucles, includes y delimitadores configurables
+# Author: Luis GuLo
+# Version: 0.4.0
+# Dependencies: bash, sed, tee, grep, cat
+
+# 🧭 Detección de raíz del proyecto
+PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
+TEMPLATE_DIR="$PROJECT_ROOT/core/templates"
+
+template_task() {
+ local host="$1"; shift
+ declare -A args
+ local start_delim="{{" end_delim="}}" strict="false"
+ declare -A vars
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/template.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ # Parsear argumentos
+ for arg in "$@"; do
+ key="${arg%%=*}"
+ value="${arg#*=}"
+ case "$key" in
+ src) src="$value" ;;
+ dest) dest="$value" ;;
+ become) become="$value" ;;
+ delimiters)
+ start_delim="${value%% *}"
+ end_delim="${value#* }"
+ ;;
+ strict) strict="$value" ;;
+ *) vars["$key"]="$value" ;;
+ esac
+ done
+
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ local template_path="$TEMPLATE_DIR/$src"
+ [[ ! -f "$template_path" ]] && echo "$(render_msg "${tr[missing_template]}" "path=$template_path")" && return 1
+
+ local rendered=""
+ local line loop_active="false" loop_key="" loop_buffer=()
+
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ if [[ "$line" =~ ${start_delim}[[:space:]]*include[[:space:]]*\"([^\"]+)\"[[:space:]]*${end_delim} ]]; then
+ include_file="${BASH_REMATCH[1]}"
+ include_path="$TEMPLATE_DIR/$include_file"
+ [[ -f "$include_path" ]] && rendered+=$(cat "$include_path")$'\n'
+ continue
+ fi
+
+ if [[ "$line" =~ ^#LOOP[[:space:]]+([a-zA-Z0-9_]+)$ ]]; then
+ loop_active="true"
+ loop_key="${BASH_REMATCH[1]}"
+ loop_buffer=()
+ continue
+ fi
+
+ if [[ "$line" == "#ENDLOOP" ]]; then
+ loop_active="false"
+ IFS=',' read -r -a items <<< "${vars[$loop_key]}"
+ for item in "${items[@]}"; do
+ for loop_line in "${loop_buffer[@]}"; do
+ rendered+=$(replace_vars "$loop_line" "$item" "$start_delim" "$end_delim")$'\n'
+ done
+ done
+ continue
+ fi
+
+ if [[ "$loop_active" == "true" ]]; then
+ loop_buffer+=("$line")
+ continue
+ fi
+
+ rendered+=$(replace_vars "$line" "" "$start_delim" "$end_delim")$'\n'
+ done < "$template_path"
+
+ if [[ "$strict" == "true" ]]; then
+ missing=$(echo "$rendered" | grep -o "${start_delim}[^${end_delim}]*${end_delim}" | sort -u)
+ if [[ -n "$missing" ]]; then
+ echo "${tr[missing_vars]:-❌ [template] Variables no definidas:}"
+ echo "$missing"
+ return 1
+ fi
+ fi
+
+ echo "$rendered" | $prefix tee "$dest" > /dev/null
+ echo "$(render_msg "${tr[generated]}" "dest=$dest")"
+}
+
+replace_vars() {
+ local line="$1"
+ local item="$2"
+ local start_delim="$3"
+ local end_delim="$4"
+ for key in "${!vars[@]}"; do
+ line="${line//${start_delim}${key}${end_delim}/${vars[$key]}}"
+ done
+ [[ -n "$item" ]] && line="${line//${start_delim}item${end_delim}/$item}"
+ echo "$line"
+}
+
+check_dependencies_template() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/template.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ local missing=()
+ for cmd in sed tee grep cat; do
+ command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
+ done
+
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ echo "$(render_msg "${tr[missing_deps]}" "cmds=${missing[*]}")"
+ return 1
+ fi
+
+ echo "${tr[deps_ok]:-✅ [template] Todas las dependencias están disponibles}"
+ return 0
+}
diff --git a/core/modules/template.tr.en b/core/modules/template.tr.en
new file mode 100644
index 0000000..8c985d8
--- /dev/null
+++ b/core/modules/template.tr.en
@@ -0,0 +1,5 @@
+missing_template=❌ [template] Template not found: {path}
+missing_vars=❌ [template] Undefined variables:
+generated=✅ [template] File generated: {dest}
+missing_deps=❌ [template] Missing dependencies: {cmds}
+deps_ok=✅ [template] All dependencies are available
diff --git a/core/modules/template.tr.es b/core/modules/template.tr.es
new file mode 100644
index 0000000..f9bdff4
--- /dev/null
+++ b/core/modules/template.tr.es
@@ -0,0 +1,5 @@
+missing_template=❌ [template] Plantilla no encontrada: {path}
+missing_vars=❌ [template] Variables no definidas:
+generated=✅ [template] Archivo generado: {dest}
+missing_deps=❌ [template] Dependencias faltantes: {cmds}
+deps_ok=✅ [template] Todas las dependencias están disponibles
diff --git a/core/modules/user.tr.en b/core/modules/user.tr.en
new file mode 100644
index 0000000..9e1d177
--- /dev/null
+++ b/core/modules/user.tr.en
@@ -0,0 +1,15 @@
+priv_required=❌ [users] Privileges required to manage users. Use 'become: true'.
+missing_username=❌ [users] Missing required parameter 'username'
+enter_create=🔧 [users] Entering create
+enter_modify=🔧 [users] Entering modify
+enter_absent=🔧 [users] Entering absent
+exists=✅ [users] User '{username}' already exists
+not_exists=⚠️ [users] User '{username}' does not exist
+group_create=🔧 [users] Creating group '{groups}'
+created=✅ [users] User '{username}' created
+modified=✅ [users] User '{username}' modified
+deleted=✅ [users] User '{username}' deleted
+already_deleted=✅ [users] User '{username}' already deleted
+unsupported_state=❌ [users] State '{state}' not supported. Use create, modify or absent.
+missing_deps=❌ [users] Missing dependencies: {cmds}
+deps_ok=✅ [users] All dependencies are present
diff --git a/core/modules/user.tr.es b/core/modules/user.tr.es
new file mode 100644
index 0000000..1820c74
--- /dev/null
+++ b/core/modules/user.tr.es
@@ -0,0 +1,15 @@
+priv_required=❌ [users] Se requieren privilegios para gestionar usuarios. Usa 'become: true'.
+missing_username=❌ [users] Falta el parámetro obligatorio 'username'
+enter_create=🔧 [users] Entrando en create
+enter_modify=🔧 [users] Entrando en modify
+enter_absent=🔧 [users] Entrando en absent
+exists=✅ [users] Usuario '{username}' ya existe
+not_exists=⚠️ [users] Usuario '{username}' no existe
+group_create=🔧 [users] Creando grupo '{groups}'
+created=✅ [users] Usuario '{username}' creado
+modified=✅ [users] Usuario '{username}' modificado
+deleted=✅ [users] Usuario '{username}' eliminado
+already_deleted=✅ [users] Usuario '{username}' ya eliminado
+unsupported_state=❌ [users] Estado '{state}' no soportado. Usa create, modify o absent.
+missing_deps=❌ [users] Dependencias faltantes: {cmds}
+deps_ok=✅ [users] Todas las dependencias están presentes
diff --git a/core/modules/users.sh b/core/modules/users.sh
new file mode 100644
index 0000000..e7306e2
--- /dev/null
+++ b/core/modules/users.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+# Module: users
+# Description: Gestiona usuarios del sistema (crear, modificar, eliminar)
+# Author: Luis GuLo
+# Version: 1.4.0
+# Dependencies: id, useradd, usermod, userdel, groupadd, sudo
+
+users_task() {
+ local host="$1"; shift
+ declare -A args; for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local username="${args[username]}"
+ local home="${args[home]:-/home/$username}"
+ local shell="${args[shell]:-/bin/bash}"
+ local groups="${args[groups]:-}"
+ local state="${args[state]:-create}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # 🌐 Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/users.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ # 🛡️ Validación
+ if [[ "$become" != "true" && "$EUID" -ne 0 ]]; then
+ echo "${tr[priv_required]:-❌ [users] Se requieren privilegios para gestionar usuarios. Usa 'become: true'.}"
+ return 1
+ fi
+
+ if [[ -z "$username" ]]; then
+ echo "${tr[missing_username]:-❌ [users] Falta el parámetro obligatorio 'username'}"
+ return 1
+ fi
+
+ case "$state" in
+ create)
+ echo "${tr[enter_create]:-🔧 [users] Entrando en create}"
+ if id "$username" &>/dev/null; then
+ echo "$(render_msg "${tr[exists]}" "username=$username")"
+ return 0
+ fi
+ if [[ -n "$groups" && "$groups" != "$username" ]]; then
+ if ! getent group "$groups" &>/dev/null; then
+ echo "$(render_msg "${tr[group_create]}" "groups=$groups")"
+ $prefix groupadd "$groups"
+ fi
+ fi
+ local cmd="$prefix useradd -m \"$username\" -s \"$shell\" -d \"$home\""
+ [[ -n "$groups" ]] && cmd="$cmd -G \"$groups\""
+ eval "$cmd" && echo "$(render_msg "${tr[created]}" "username=$username")"
+ ;;
+ modify)
+ echo "${tr[enter_modify]:-🔧 [users] Entrando en modify}"
+ if ! id "$username" &>/dev/null; then
+ echo "$(render_msg "${tr[not_exists]}" "username=$username")"
+ return 1
+ fi
+ local cmd="$prefix usermod \"$username\""
+ [[ -n "$shell" ]] && cmd="$cmd -s \"$shell\""
+ [[ -n "$home" ]] && cmd="$cmd -d \"$home\""
+ [[ -n "$groups" ]] && cmd="$cmd -G \"$groups\""
+ eval "$cmd" && echo "$(render_msg "${tr[modified]}" "username=$username")"
+ ;;
+ absent)
+ echo "${tr[enter_absent]:-🔧 [users] Entrando en absent}"
+ if ! id "$username" &>/dev/null; then
+ echo "$(render_msg "${tr[already_deleted]}" "username=$username")"
+ return 0
+ fi
+ eval "$prefix userdel -r \"$username\"" && echo "$(render_msg "${tr[deleted]}" "username=$username")"
+ ;;
+ *)
+ echo "$(render_msg "${tr[unsupported_state]}" "state=$state")"
+ return 1
+ ;;
+ esac
+}
+
+check_dependencies_users() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/users.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ local missing=()
+ for cmd in id sudo; do
+ if ! command -v "$cmd" >/dev/null 2>&1; then
+ missing+=("$cmd")
+ fi
+ done
+
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ echo "$(render_msg "${tr[missing_deps]}" "cmds=${missing[*]}")"
+ return 1
+ fi
+
+ echo "${tr[deps_ok]:-✅ [users] Todas las dependencias están presentes}"
+ return 0
+}
diff --git a/core/modules/vault-remote.sh b/core/modules/vault-remote.sh
new file mode 100644
index 0000000..4b3a1c6
--- /dev/null
+++ b/core/modules/vault-remote.sh
@@ -0,0 +1,81 @@
+#!/bin/bash
+# Module: vault-remote
+# Description: Sincroniza secretos cifrados entre vault local y remoto
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.1.0
+# Dependencies: ssh, scp, gpg
+
+VAULT_DIR="core/vault"
+
+vault_remote_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do
+ key="${arg%%=*}"
+ value="${arg#*=}"
+ args["$key"]="$value"
+ done
+
+ local action="${args[action]}"
+ local key="${args[key]}"
+ local remote_path="${args[remote_path]:-/tmp/shflow_vault}"
+ local become="${args[become]}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ # Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/vault-remote.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ case "$action" in
+ push)
+ if [ ! -f "$VAULT_DIR/$key.gpg" ]; then
+ echo "$(render_msg "${tr[missing_local]}" "key=$key")"
+ return 1
+ fi
+ scp "$VAULT_DIR/$key.gpg" "$host:$remote_path/$key.gpg"
+ ssh "$host" "$prefix mkdir -p '$remote_path'"
+ echo "$(render_msg "${tr[pushed]}" "key=$key" "host=$host" "path=$remote_path")"
+ ;;
+ pull)
+ ssh "$host" "$prefix test -f '$remote_path/$key.gpg'" || {
+ echo "$(render_msg "${tr[missing_remote]}" "key=$key")"
+ return 1
+ }
+ scp "$host:$remote_path/$key.gpg" "$VAULT_DIR/$key.gpg"
+ echo "$(render_msg "${tr[pulled]}" "key=$key" "host=$host")"
+ ;;
+ sync)
+ ssh "$host" "$prefix mkdir -p '$remote_path'"
+ scp "$VAULT_DIR/"*.gpg "$host:$remote_path/"
+ echo "$(render_msg "${tr[synced]}" "host=$host" "path=$remote_path")"
+ ;;
+ *)
+ echo "$(render_msg "${tr[unsupported]}" "action=$action")"
+ return 1
+ ;;
+ esac
+}
+
+check_dependencies_vault_remote() {
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/vault-remote.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"
+ fi
+
+ for cmd in ssh scp gpg; do
+ if ! command -v "$cmd" &> /dev/null; then
+ echo "$(render_msg "${tr[missing_deps]}" "cmd=$cmd")"
+ return 1
+ fi
+ done
+ echo "${tr[deps_ok]:-✅ [vault-remote] Dependencias disponibles.}"
+ return 0
+}
diff --git a/core/modules/vault-remote.tr.en b/core/modules/vault-remote.tr.en
new file mode 100644
index 0000000..cb0b856
--- /dev/null
+++ b/core/modules/vault-remote.tr.en
@@ -0,0 +1,8 @@
+missing_local=❌ [vault-remote] Secret '{key}' does not exist locally.
+missing_remote=❌ [vault-remote] Secret '{key}' does not exist on remote host.
+pushed=📤 Secret '{key}' sent to {host}:{path}
+pulled=📥 Secret '{key}' retrieved from {host}
+synced=🔄 Vault synchronized with {host}:{path}
+unsupported=❌ [vault-remote] Action '{action}' not supported.
+missing_deps=❌ [vault-remote] Command '{cmd}' not available.
+deps_ok=✅ [vault-remote] Dependencies available.
diff --git a/core/modules/vault-remote.tr.es b/core/modules/vault-remote.tr.es
new file mode 100644
index 0000000..79a6106
--- /dev/null
+++ b/core/modules/vault-remote.tr.es
@@ -0,0 +1,8 @@
+missing_local=❌ [vault-remote] Secreto '{key}' no existe localmente.
+missing_remote=❌ [vault-remote] Secreto '{key}' no existe en el host remoto.
+pushed=📤 Secreto '{key}' enviado a {host}:{path}
+pulled=📥 Secreto '{key}' recuperado desde {host}
+synced=🔄 Vault sincronizado con {host}:{path}
+unsupported=❌ [vault-remote] Acción '{action}' no soportada.
+missing_deps=❌ [vault-remote] Comando '{cmd}' no disponible.
+deps_ok=✅ [vault-remote] Dependencias disponibles.
diff --git a/core/modules/wait.sh b/core/modules/wait.sh
new file mode 100644
index 0000000..409dfbe
--- /dev/null
+++ b/core/modules/wait.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# Module: wait
+# Description: Pausa la ejecución durante un número de segundos (soporta decimales)
+# Author: Luis GuLo
+# Version: 1.2.0
+# Dependencies: sleep
+
+wait_task() {
+ local host="$1"; shift
+ declare -A args; for arg in "$@"; do key="${arg%%=*}"; value="${arg#*=}"; args["$key"]="$value"; done
+
+ local seconds="${args[seconds]:-1}"
+
+ # Cargar traducciones
+ local lang="${shflow_vars[language]:-es}"
+ local trfile="$(dirname "${BASH_SOURCE[0]}")/wait.tr.${lang}"
+ declare -A tr
+ if [[ -f "$trfile" ]]; then
+ while IFS='=' read -r key val; do tr["$key"]="$val"; done < "$trfile"
+ fi
+
+ if ! [[ "$seconds" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
+ echo "${tr[invalid]:-❌ [wait] El parámetro 'seconds' debe ser un número válido (entero o decimal)}"
+ return 1
+ fi
+
+ echo "$(render_msg "${tr[start]}" "seconds=$seconds")"
+ sleep "$seconds"
+ echo "${tr[done]:-✅ [wait] Pausa completada}"
+}
+
+check_dependencies_wait() {
+ command -v sleep &>/dev/null || {
+ echo "${tr[missing_deps]:-❌ [wait] El comando 'sleep' no está disponible}"
+ return 1
+ }
+ echo "${tr[deps_ok]:-✅ [wait] Dependencias OK}"
+ return 0
+}
diff --git a/core/modules/wait.tr.en b/core/modules/wait.tr.en
new file mode 100644
index 0000000..a603364
--- /dev/null
+++ b/core/modules/wait.tr.en
@@ -0,0 +1,5 @@
+invalid=❌ [wait] The 'seconds' parameter must be a valid number (integer or decimal)
+start=⏳ [wait] Waiting for {seconds} seconds...
+done=✅ [wait] Pause completed
+missing_deps=❌ [wait] The 'sleep' command is not available
+deps_ok=✅ [wait] Dependencies OK
diff --git a/core/modules/wait.tr.es b/core/modules/wait.tr.es
new file mode 100644
index 0000000..764ed3f
--- /dev/null
+++ b/core/modules/wait.tr.es
@@ -0,0 +1,5 @@
+invalid=❌ [wait] El parámetro 'seconds' debe ser un número válido (entero o decimal)
+start=⏳ [wait] Esperando {seconds} segundos...
+done=✅ [wait] Pausa completada
+missing_deps=❌ [wait] El comando 'sleep' no está disponible
+deps_ok=✅ [wait] Dependencias OK
diff --git a/core/templates/.gitignore b/core/templates/.gitignore
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/core/templates/.gitignore
diff --git a/core/utils/eg.sh b/core/utils/eg.sh
new file mode 100755
index 0000000..551c9d0
--- /dev/null
+++ b/core/utils/eg.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+Qes="8J+lmiBIYXMgZW5jb250cmFkbyBlbCBodWV2byBkZSBwYXNjdWEuIMKhQnVlbiBvam8h·4pyoIE5vIHRvZG9zIGxvcyBzY3JpcHRzIHRpZW5lbiBhbG1hLi4uIHBlcm8gZXN0ZSBzw60u·8J+noCBFbCBtZWpvciBidWcgZXMgZWwgcXVlIG51bmNhIGV4aXN0acOzLg==·8J+QoyBTaEZsb3cgdGUgc2FsdWRhIGRlc2RlIGxhcyBzb21icmFzLg==·8J+TnCBMYSBhdXRvbWF0aXphY2nDs24gdGFtYmnDqW4gdGllbmUgcG9lc8OtYS4="
+Qen="8J+lmiBZb3UgZm91bmQgdGhlIEVhc3RlciBlZ2cuIFNoYXJwIGV5ZSE=·4pyoIE5vdCBhbGwgc2NyaXB0cyBoYXZlIHNvdWwuLi4gYnV0IHRoaXMgb25lIGRvZXMu·8J+noCBUaGUgYmVzdCBidWcgaXMgdGhlIG9uZSB0aGF0IG5ldmVyIGV4aXN0ZWQu·8J+QoyBTaEZsb3cgZ3JlZXRzIHlvdSBmcm9tIHRoZSBzaGFkb3dzLg==·8J+TnCBBdXRvbWF0aW9uIGhhcyBwb2V0cnkgdG9vLg=="
+vhs=([0]="448b55f2" [1]="c9f66247" [2]="154f020f" [3]="0e931208" [4]="d2e2fa57")
+
+sheg() {
+ local P="$1" ; P=$((P+1))
+ echo "――――――"
+ echo "$Qes" |awk -F '·' -v p="$P" '{print $p}' | base64 -d; echo
+ echo "$Qen" |awk -F '·' -v p="$P" '{print $p}' | base64 -d; echo
+ echo "――――――"
+}
+
+main() {
+ [[ $# -lt 1 ]] && return 0
+ local input="$1" ; local hsh
+ hsh=$(echo -n "$input" | md5sum | cut -c1-8)
+ for n in "${!vhs[@]}"; do
+ if [[ "$hsh" == "${vhs[$n]}" ]]; then
+ sheg "$n" ; break
+ fi
+ done
+ return 0
+}
+
+main "$@"
diff --git a/core/utils/module-docgen.sh b/core/utils/module-docgen.sh
new file mode 100755
index 0000000..f5461cd
--- /dev/null
+++ b/core/utils/module-docgen.sh
@@ -0,0 +1,69 @@
+#!/bin/bash
+# ShFlow Module Documentation Generator
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.4.0
+
+set -euo pipefail
+
+# 🧭 Detección de la raíz del proyecto
+PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
+OUTPUT="$PROJECT_ROOT/docs/modules-list.md"
+MODULE_DIRS=("$PROJECT_ROOT/core/modules" "$PROJECT_ROOT/user_modules" "$PROJECT_ROOT/community_modules")
+
+export SHFLOW_LANG="${SHFLOW_LANG:-es}"
+
+# 🧩 Cargar render_msg si no está disponible
+COMMON_LIB="$PROJECT_ROOT/core/lib/translate_msg.sh"
+if ! declare -f render_msg &>/dev/null; then
+ [[ -f "$COMMON_LIB" ]] && source "$COMMON_LIB"
+fi
+
+# 🌐 Cargar traducciones
+lang="${SHFLOW_LANG:-es}"
+
+trfile="$PROJECT_ROOT/core/utils/module-docgen.tr.${lang}"
+declare -A tr
+if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+# 📝 Encabezado del documento
+{
+ echo "${tr[title]:-# 🧩 Módulos en ShFlow}"
+ echo ""
+ echo "**$(render_msg "${tr[generated]}" "date=$(date '+%Y-%m-%d %H:%M:%S')")**"
+ echo ""
+ echo "| ${tr[col_module]:-Módulo} | ${tr[col_desc]:-Descripción} | ${tr[col_type]:-Tipo} | ${tr[col_author]:-Autor} | ${tr[col_version]:-Versión} | ${tr[col_deps]:-Dependencias} |"
+ echo "|--------|-------------|------|-------|---------|--------------|"
+} > "$OUTPUT"
+
+# 🔁 Procesar módulos
+for dir in "${MODULE_DIRS[@]}"; do
+ [ -d "$dir" ] || continue
+ TYPE=$(echo "$dir" | sed "s#$PROJECT_ROOT/##g")
+ while IFS= read -r -d '' file; do
+ name=$(basename "$file" .sh)
+ desc=$(grep -E '^# Description:' "$file" | sed 's/^# Description:[[:space:]]*//')
+ author=$(grep -E '^# Author:' "$file" | sed 's/^# Author:[[:space:]]*//')
+ version=$(grep -E '^# Version:' "$file" | sed 's/^# Version:[[:space:]]*//')
+ deps=$(grep -E '^# Dependencies:' "$file" | sed 's/^# Dependencies:[[:space:]]*//')
+
+ # Asegurar valor minimo
+ name=${name:-""}
+ desc=${desc:-""}
+ author=${author:-""}
+ version=${version:-""}
+ deps=${deps:-""}
+
+ [[ -z "$name" ]] && continue
+
+ echo "| $name | $desc | $TYPE | $author | $version | $deps |" >> "$OUTPUT"
+ done < <(find "$dir" -type f -name "*.sh" -print0)
+done
+
+# 📌 Pie de página
+{
+ echo ""
+ echo "${tr[footer]:-_Para actualizar esta tabla, ejecuta: \`module-docgen\`_}"
+} >> "$OUTPUT"
+
+echo "$(render_msg "${tr[done]}" "path=$OUTPUT")"
diff --git a/core/utils/module-docgen.tr.en b/core/utils/module-docgen.tr.en
new file mode 100644
index 0000000..644b2c3
--- /dev/null
+++ b/core/utils/module-docgen.tr.en
@@ -0,0 +1,10 @@
+title=# 🧩 Modules in ShFlow
+generated=Automatically generated on {date}
+col_module=Module
+col_desc=Description
+col_type=Type
+col_author=Author
+col_version=Version
+col_deps=Dependencies
+footer=_To update this table, run: `module-docgen`_
+done=✅ Documentation generated at {path}
diff --git a/core/utils/module-docgen.tr.es b/core/utils/module-docgen.tr.es
new file mode 100644
index 0000000..14b68fa
--- /dev/null
+++ b/core/utils/module-docgen.tr.es
@@ -0,0 +1,10 @@
+title=# 🧩 Módulos en ShFlow
+generated=Generado automáticamente el {date}
+col_module=Módulo
+col_desc=Descripción
+col_type=Tipo
+col_author=Autor
+col_version=Versión
+col_deps=Dependencias
+footer=_Para actualizar esta tabla, ejecuta: `module-docgen`_
+done=✅ Documentación generada en {path}
diff --git a/core/utils/module-template.sh b/core/utils/module-template.sh
new file mode 100755
index 0000000..a38624b
--- /dev/null
+++ b/core/utils/module-template.sh
@@ -0,0 +1,76 @@
+#!/bin/bash
+# ShFlow Module Template Generator
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.1.0
+
+set -euo pipefail
+
+# 📁 Rutas defensivas
+PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
+MODULE_NAME="${1:-}"
+MODULE_DIR="$PROJECT_ROOT/core/modules"
+MODULE_FILE="$MODULE_DIR/$MODULE_NAME.sh"
+
+# 🧩 Cargar render_msg si no está disponible
+COMMON_LIB="$PROJECT_ROOT/core/lib/translate_msg.sh"
+if ! declare -f render_msg &>/dev/null; then
+ [[ -f "$COMMON_LIB" ]] && source "$COMMON_LIB"
+fi
+
+# 🌐 Cargar traducciones
+lang="${SHFLOW_LANG:-es}"
+trfile="$PROJECT_ROOT/core/utils/module-template.tr.${lang}"
+declare -A tr
+if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+# 🧪 Validar entrada
+if [[ -z "$MODULE_NAME" ]]; then
+ echo "${tr[usage]:-❌ Uso: module-template.sh <nombre_modulo>}"
+ exit 1
+fi
+
+if [[ -f "$MODULE_FILE" ]]; then
+ echo "$(render_msg "${tr[exists]}" "name=$MODULE_NAME" "dir=$MODULE_DIR")"
+ exit 1
+fi
+
+mkdir -p "$MODULE_DIR"
+
+cat > "$MODULE_FILE" <<EOF
+#!/bin/bash
+# Module: $MODULE_NAME
+# Description: <descripción breve del módulo>
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.0
+# Dependencies: <comandos externos si aplica>
+
+${MODULE_NAME}_task() {
+ local host="\$1"; shift
+ declare -A args
+ for arg in "\$@"; do
+ key="\${arg%%=*}"
+ value="\${arg#*=}"
+ args["\$key"]="\$value"
+ done
+
+ echo "🚧 Ejecutando módulo '$MODULE_NAME' en \$host"
+ # Aquí va la lógica principal
+}
+
+check_dependencies_$MODULE_NAME() {
+ # Verifica herramientas necesarias
+ for cmd in <comando1> <comando2>; do
+ if ! command -v "\$cmd" &> /dev/null; then
+ echo " ❌ [$MODULE_NAME] Falta: \$cmd"
+ return 1
+ fi
+ done
+ echo " ✅ [$MODULE_NAME] Dependencias OK"
+ return 0
+}
+EOF
+
+chmod +x "$MODULE_FILE"
+echo "$(render_msg "${tr[created]}" "name=$MODULE_NAME" "path=$MODULE_FILE")"
diff --git a/core/utils/module-template.tr.en b/core/utils/module-template.tr.en
new file mode 100644
index 0000000..bc948d7
--- /dev/null
+++ b/core/utils/module-template.tr.en
@@ -0,0 +1,3 @@
+usage=❌ Usage: module-template.sh <module_name>
+exists=⚠️ Module '{name}' already exists in {dir}
+created=✅ Module '{name}' created at {path}
diff --git a/core/utils/module-template.tr.es b/core/utils/module-template.tr.es
new file mode 100644
index 0000000..4dc6e63
--- /dev/null
+++ b/core/utils/module-template.tr.es
@@ -0,0 +1,3 @@
+usage=❌ Uso: module-template.sh <nombre_modulo>
+exists=⚠️ El módulo '{name}' ya existe en {dir}
+created=✅ Módulo '{name}' creado en {path}
diff --git a/core/utils/shflow-check.sh b/core/utils/shflow-check.sh
new file mode 100755
index 0000000..130eca0
--- /dev/null
+++ b/core/utils/shflow-check.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+# ShFlow Environment Checker
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.5.0
+
+set -e
+
+PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
+
+MODULE_PATHS=(
+ "$PROJECT_ROOT/core/modules"
+ "$PROJECT_ROOT/user_modules"
+ "$PROJECT_ROOT/community_modules"
+)
+
+# 🧩 Cargar funciones comunes si no están disponibles
+COMMON_LIB="$PROJECT_ROOT/core/lib/translate_msg.sh"
+if ! declare -f render_msg &>/dev/null; then
+ [[ -f "$COMMON_LIB" ]] && source "$COMMON_LIB"
+fi
+
+GLOBAL_TOOLS=("bash" "ssh" "scp" "git" "curl" "jq" "yq" "gpg")
+
+REQUIRED_PATHS=(
+ "$PROJECT_ROOT/core/modules"
+ "$PROJECT_ROOT/core/utils"
+ "$PROJECT_ROOT/core/inventory"
+ "$PROJECT_ROOT/examples"
+ "$PROJECT_ROOT/user_modules"
+ "$PROJECT_ROOT/community_modules"
+ "$PROJECT_ROOT/shflow.sh"
+ "$PROJECT_ROOT/vault.sh"
+)
+
+# 🌐 Cargar traducciones
+lang="${shflow_vars[language]:-es}"
+trfile="$PROJECT_ROOT/core/utils/shflow-check.tr.${lang}"
+declare -A tr
+if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+check_global_tools() {
+ echo "${tr[tools_header]:-🔍 Verificando herramientas globales...}"
+ local missing=0
+ for tool in "${GLOBAL_TOOLS[@]}"; do
+ if ! command -v "$tool" &> /dev/null; then
+ echo "$(render_msg "${tr[tool_missing]}" "tool=$tool")"
+ missing=1
+ else
+ echo "$(render_msg "${tr[tool_ok]}" "tool=$tool")"
+ fi
+ done
+ return $missing
+}
+
+check_structure() {
+ echo ""
+ echo "${tr[structure_header]:-📁 Verificando estructura de ShFlow...}"
+ local missing=0
+ for path in "${REQUIRED_PATHS[@]}"; do
+ if [ ! -e "$path" ]; then
+ echo "$(render_msg "${tr[path_missing]}" "path=$path")"
+ missing=1
+ else
+ echo "$(render_msg "${tr[path_ok]}" "path=$path")"
+ fi
+ done
+ return $missing
+}
+
+load_and_check_modules() {
+ echo ""
+ echo "${tr[modules_header]:-🔍 Verificando módulos ShFlow...}"
+ for dir in "${MODULE_PATHS[@]}"; do
+ [ -d "$dir" ] || continue
+ while IFS= read -r -d '' mod; do
+ source "$mod"
+ done < <(find "$dir" -type f -name "*.sh" -print0)
+ done
+
+ for func in $(declare -F | awk '{print $3}' | grep '^check_dependencies_'); do
+ echo ""
+ echo "$(render_msg "${tr[checking_func]}" "func=$func")"
+ $func || echo "$(render_msg "${tr[func_warn]}" "func=$func")"
+ done
+}
+
+main() {
+ echo "${tr[title]:-🧪 ShFlow Environment Check}"
+ echo "${tr[separator]:-=============================}"
+
+ check_global_tools
+ check_structure
+ load_and_check_modules
+
+ echo ""
+ echo "${tr[done]:-✅ Verificación completada.}"
+}
+
+main "$@"
diff --git a/core/utils/shflow-check.tr.en b/core/utils/shflow-check.tr.en
new file mode 100644
index 0000000..6430ffd
--- /dev/null
+++ b/core/utils/shflow-check.tr.en
@@ -0,0 +1,12 @@
+title=🧪 ShFlow Environment Check
+separator=_____________________________
+tools_header=🔍 Checking global tools...
+tool_missing=❌ {tool} not found
+tool_ok=✅ {tool} available
+structure_header=📁 Checking ShFlow structure...
+path_missing=❌ Missing: {path}
+path_ok=✅ Found: {path}
+modules_header=🔍 Checking ShFlow modules...
+checking_func=🔧 Running {func}...
+func_warn=⚠️ Incomplete dependencies in {func}
+done=✅ Check completed.
diff --git a/core/utils/shflow-check.tr.es b/core/utils/shflow-check.tr.es
new file mode 100644
index 0000000..983f318
--- /dev/null
+++ b/core/utils/shflow-check.tr.es
@@ -0,0 +1,12 @@
+title=🧪 ShFlow Environment Check
+separator=_____________________________
+tools_header=🔍 Verificando herramientas globales...
+tool_missing=❌ {tool} no encontrado
+tool_ok=✅ {tool} disponible
+structure_header=📁 Verificando estructura de ShFlow...
+path_missing=❌ Falta: {path}
+path_ok=✅ Encontrado: {path}
+modules_header=🔍 Verificando módulos ShFlow...
+checking_func=🔧 Ejecutando {func}...
+func_warn=⚠️ Dependencias incompletas en {func}
+done=✅ Verificación completada.
diff --git a/core/utils/shflow-doc.sh b/core/utils/shflow-doc.sh
new file mode 100755
index 0000000..861f1b9
--- /dev/null
+++ b/core/utils/shflow-doc.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# ShFlow Doc Generator
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.1.0
+
+set -e
+
+# ─────────────────────────────────────────────
+# 🧭 Detección de la raíz del proyecto
+PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
+
+# 🧩 Cargar funciones comunes si no están disponibles
+COMMON_LIB="$PROJECT_ROOT/core/lib/translate_msg.sh"
+if ! declare -f render_msg &>/dev/null; then
+ [[ -f "$COMMON_LIB" ]] && source "$COMMON_LIB"
+fi
+
+# 🌐 Cargar traducciones
+lang="${shflow_vars[language]:-es}"
+trfile="$PROJECT_ROOT/core/utils/shflow-doc.tr.${lang}"
+declare -A tr
+if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+MODULE_PATHS=(
+ "$PROJECT_ROOT/core/modules"
+ "$PROJECT_ROOT/user_modules"
+ "$PROJECT_ROOT/community_modules"
+)
+
+extract_metadata() {
+ local file="$1"
+ local module desc author version deps
+
+ module=$(grep -m1 '^# Module:' "$file" | cut -d':' -f2- | xargs)
+ desc=$(grep -m1 '^# Description:' "$file" | cut -d':' -f2- | xargs)
+ author=$(grep -m1 '^# Author:' "$file" | cut -d':' -f2- | xargs)
+ version=$(grep -m1 '^# Version:' "$file" | cut -d':' -f2- | xargs)
+ deps=$(grep -m1 '^# Dependencies:' "$file" | cut -d':' -f2- | xargs)
+
+ echo "$(render_msg "${tr[module]}" "name=$module")"
+ echo "$(render_msg "${tr[desc]}" "desc=$desc")"
+ echo "$(render_msg "${tr[author]}" "author=$author")"
+ echo "$(render_msg "${tr[version]}" "version=$version")"
+ echo "$(render_msg "${tr[deps]}" "deps=$deps")"
+ echo "${tr[separator]:- ————————————————————————}"
+}
+
+main() {
+ echo "${tr[title]:-📚 ShFlow Modules Documentation}"
+ echo "${tr[separator_line]:-=================================}"
+
+ for dir in "${MODULE_PATHS[@]}"; do
+ [ -d "$dir" ] || continue
+ ROUTE=$(echo "$dir" | sed "s#$PROJECT_ROOT/##g")
+ echo -e "\n$(render_msg "${tr[section]}" "type=$ROUTE")"
+ while IFS= read -r -d '' file; do
+ extract_metadata "$file"
+ done < <(find "$dir" -type f -name "*.sh" -print0)
+ done
+}
+
+main "$@"
diff --git a/core/utils/shflow-doc.tr.en b/core/utils/shflow-doc.tr.en
new file mode 100644
index 0000000..5d3167e
--- /dev/null
+++ b/core/utils/shflow-doc.tr.en
@@ -0,0 +1,9 @@
+title=📚 ShFlow Modules Documentation
+separator_line==================================
+section=🗃️ Module Type: {type}
+module= 📦 Module: {name}
+desc= 🔧 Description: {desc}
+author= 👤 Author: {author}
+version= 📌 Version: {version}
+deps= 📎 Dependencies: {deps}
+separator= ————————————————————————
diff --git a/core/utils/shflow-doc.tr.es b/core/utils/shflow-doc.tr.es
new file mode 100644
index 0000000..b1150f7
--- /dev/null
+++ b/core/utils/shflow-doc.tr.es
@@ -0,0 +1,9 @@
+title=📚 ShFlow Modules Documentation
+separator_line==================================
+section=🗃️ Tipo de módulo: {type}
+module= 📦 Módulo: {name}
+desc= 🔧 Descripción: {desc}
+author= 👤 Autor: {author}
+version= 📌 Versión: {version}
+deps= 📎 Dependencias: {deps}
+separator= ————————————————————————
diff --git a/core/utils/shflow-ssh-init.sh b/core/utils/shflow-ssh-init.sh
new file mode 100755
index 0000000..26d5e70
--- /dev/null
+++ b/core/utils/shflow-ssh-init.sh
@@ -0,0 +1,74 @@
+#!/bin/bash
+# Utility: shflow-ssh-init
+# Description: Inicializa acceso SSH sin contraseña en los hosts del inventario
+# Author: Luis GuLo
+# Version: 0.2.0
+
+set -euo pipefail
+
+# 📁 Rutas defensivas
+PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
+INVENTORY="$PROJECT_ROOT/core/inventory/hosts.yaml"
+TIMEOUT=5
+USER="${USER:-$(whoami)}"
+KEY="${KEY:-$HOME/.ssh/id_rsa.pub}"
+
+# 🧩 Cargar render_msg si no está disponible
+COMMON_LIB="$PROJECT_ROOT/core/lib/translate_msg.sh"
+if ! declare -f render_msg &>/dev/null; then
+ [[ -f "$COMMON_LIB" ]] && source "$COMMON_LIB"
+fi
+
+export SHFLOW_LANG="${SHFLOW_LANG:-es}"
+# 🌐 Cargar traducciones
+lang="${SHFLOW_LANG:-es}"
+
+trfile="$PROJECT_ROOT/core/utils/shflow-ssh-init.tr.${lang}"
+declare -A tr
+if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+echo "$(render_msg "${tr[start]}" "user=$USER")"
+echo "$(render_msg "${tr[inventory]}" "path=$INVENTORY")"
+echo "$(render_msg "${tr[key]}" "key=$KEY")"
+echo ""
+
+# 🧪 Validar dependencias
+for cmd in yq ssh ssh-copy-id; do
+ if ! command -v "$cmd" &>/dev/null; then
+ echo "$(render_msg "${tr[missing_dep]}" "cmd=$cmd")"
+ exit 1
+ fi
+done
+
+# 🔁 Extraer hosts
+HOSTS=()
+HOSTS_RAW=$(yq ".all.hosts | keys | .[]" "$INVENTORY")
+[ -z "$HOSTS_RAW" ] && echo "${tr[no_hosts]:-❌ No se encontraron hosts en el inventario.}" && exit 1
+
+while IFS= read -r line; do
+ HOSTS+=("$(echo "$line" | sed 's/^\"\(.*\)\"$/\1/')") # Eliminar comillas
+done <<< "$HOSTS_RAW"
+
+# 🔍 Evaluar cada host
+for host in "${HOSTS[@]}"; do
+ IP=$(yq -r ".all.hosts.\"$host\".ansible_host" "$INVENTORY")
+ [[ "$IP" == "null" || -z "$IP" ]] && echo "$(render_msg "${tr[missing_ip]}" "host=$host")" && continue
+
+ echo "$(render_msg "${tr[checking]}" "host=$host" "ip=$IP")"
+
+ if ssh -o BatchMode=yes -o ConnectTimeout=$TIMEOUT "$USER@$IP" 'true' &>/dev/null; then
+ echo "${tr[skip]:- 🔁 Inicialización SSH no es necesaria}"
+ continue
+ fi
+
+ echo "$(render_msg "${tr[copying]}" "user=$USER" "ip=$IP")"
+ if ssh-copy-id -i "$KEY" "$USER@$IP"; then
+ echo "${tr[success]:- ✅ Clave pública instalada correctamente}"
+ else
+ echo "${tr[fail]:- ❌ Fallo al instalar clave pública}"
+ fi
+
+ echo ""
+done
+
+echo "${tr[done]:-✅ Proceso de inicialización SSH completado}"
diff --git a/core/utils/shflow-ssh-init.tr.en b/core/utils/shflow-ssh-init.tr.en
new file mode 100644
index 0000000..1005b42
--- /dev/null
+++ b/core/utils/shflow-ssh-init.tr.en
@@ -0,0 +1,12 @@
+start=🔐 Initializing passwordless SSH access for user: {user}
+inventory=📁 Inventory: {path}
+key=🔑 Public key: {key}
+missing_dep=❌ Requires '{cmd}' installed on the system
+no_hosts=❌ No hosts found in inventory.
+missing_ip=⚠️ Host '{host}' has no ansible_host defined
+checking=🖥️ Host: {host} ({ip})
+skip= 🔁 SSH initialization not needed
+copying= 🚀 Running ssh-copy-id for {user}@{ip}
+success= ✅ Public key installed successfully
+fail= ❌ Failed to install public key
+done=✅ SSH initialization process completed
diff --git a/core/utils/shflow-ssh-init.tr.es b/core/utils/shflow-ssh-init.tr.es
new file mode 100644
index 0000000..c693c8f
--- /dev/null
+++ b/core/utils/shflow-ssh-init.tr.es
@@ -0,0 +1,12 @@
+start=🔐 Inicializando acceso SSH sin contraseña para usuario: {user}
+inventory=📁 Inventario: {path}
+key=🔑 Clave pública: {key}
+missing_dep=❌ Requiere '{cmd}' instalado en el sistema
+no_hosts=❌ No se encontraron hosts en el inventario.
+missing_ip=⚠️ Host '{host}' sin ansible_host definido
+checking=🖥️ Host: {host} ({ip})
+skip= 🔁 Inicialización SSH no es necesaria
+copying= 🚀 Ejecutando ssh-copy-id para {user}@{ip}
+success= ✅ Clave pública instalada correctamente
+fail= ❌ Fallo al instalar clave pública
+done=✅ Proceso de inicialización SSH completado
diff --git a/core/utils/shflow-trust.sh b/core/utils/shflow-trust.sh
new file mode 100755
index 0000000..bc27d94
--- /dev/null
+++ b/core/utils/shflow-trust.sh
@@ -0,0 +1,90 @@
+#!/bin/bash
+# Utility: shflow-trust
+# Description: Evalúa acceso SSH y privilegios sudo para cada host del inventario
+# Author: Luis GuLo
+# Version: 0.4.0
+
+set -euo pipefail
+
+# 📁 Rutas defensivas
+PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
+INVENTORY="$PROJECT_ROOT/core/inventory/hosts.yaml"
+REPORT="$PROJECT_ROOT/core/inventory/trust_report.yaml"
+TIMEOUT=5
+USER="${USER:-$(whoami)}"
+
+# 🧩 Cargar render_msg si no está disponible
+COMMON_LIB="$PROJECT_ROOT/core/lib/translate_msg.sh"
+if ! declare -f render_msg &>/dev/null; then
+ [[ -f "$COMMON_LIB" ]] && source "$COMMON_LIB"
+fi
+
+export SHFLOW_LANG="${SHFLOW_LANG:-es}"
+# 🌐 Cargar traducciones
+lang="${SHFLOW_LANG:-es}"
+
+trfile="$PROJECT_ROOT/core/utils/shflow-trust.tr.${lang}"
+declare -A tr
+if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+echo "$(render_msg "${tr[start]}" "user=$USER")"
+echo "$(render_msg "${tr[inventory]}" "path=$INVENTORY")"
+echo "$(render_msg "${tr[report]}" "path=$REPORT")"
+echo ""
+
+# 🧪 Validar dependencia yq
+if ! command -v yq &>/dev/null; then
+ echo "$(render_msg "${tr[missing_dep]}" "cmd=yq")"
+ exit 1
+fi
+
+# 🧹 Regenerar informe
+{
+ echo "# $(render_msg "${tr[report_title]}")"
+ echo "# $(render_msg "${tr[report_date]}" "date=$(date)")"
+ echo ""
+} > "$REPORT"
+
+# 🔁 Extraer hosts
+HOSTS=()
+HOSTS_RAW=$(yq ".all.hosts | keys | .[]" "$INVENTORY")
+[ -z "$HOSTS_RAW" ] && echo "${tr[no_hosts]:-❌ No se encontraron hosts en el inventario.}" && exit 1
+
+while IFS= read -r line; do
+ HOSTS+=("$(echo "$line" | sed 's/^\"\(.*\)\"$/\1/')") # Eliminar comillas
+done <<< "$HOSTS_RAW"
+
+# 🔍 Evaluar cada host
+for host in "${HOSTS[@]}"; do
+ IP=$(yq -r ".all.hosts.\"$host\".ansible_host" "$INVENTORY")
+ [[ "$IP" == "null" || -z "$IP" ]] && echo "$(render_msg "${tr[missing_ip]}" "host=$host")" && continue
+
+ echo "$(render_msg "${tr[checking]}" "host=$host" "ip=$IP")"
+
+ if ssh -o BatchMode=yes -o ConnectTimeout=$TIMEOUT "$USER@$IP" 'echo ok' &>/dev/null; then
+ echo "${tr[ssh_ok]:- ✅ SSH: ok}"
+ SSH_STATUS="ok"
+
+ if ssh -o BatchMode=yes "$USER@$IP" 'sudo -n true' &>/dev/null; then
+ echo "${tr[sudo_ok]:- ✅ SUDO: ok}"
+ SUDO_STATUS="ok"
+ else
+ echo "${tr[sudo_pw]:- ⚠️ SUDO: requiere contraseña o no permitido}"
+ SUDO_STATUS="password_required"
+ fi
+ else
+ echo "${tr[ssh_fail]:- ❌ SSH: fallo de conexión}"
+ SSH_STATUS="failed"
+ SUDO_STATUS="unknown"
+ fi
+
+ {
+ echo "$host:"
+ echo " ip: $IP"
+ echo " ssh: $SSH_STATUS"
+ echo " sudo: $SUDO_STATUS"
+ echo ""
+ } >> "$REPORT"
+done
+
+echo "$(render_msg "${tr[done]}" "path=$REPORT")"
diff --git a/core/utils/shflow-trust.tr.en b/core/utils/shflow-trust.tr.en
new file mode 100644
index 0000000..5e1209f
--- /dev/null
+++ b/core/utils/shflow-trust.tr.en
@@ -0,0 +1,14 @@
+start=🔍 Evaluating SSH and sudo trust for user: {user}
+inventory=📁 Inventory: {path}
+report=📄 Report: {path}
+missing_dep=❌ Requires '{cmd}' (Go version) to process YAML inventory
+report_title=Trust report generated by shflow-trust
+report_date=Date: {date}
+no_hosts=❌ No hosts found in inventory.
+missing_ip=⚠️ Host '{host}' has no ansible_host defined
+checking=🖥️ Host: {host} ({ip})
+ssh_ok= ✅ SSH: ok
+ssh_fail= ❌ SSH: connection failed
+sudo_ok= ✅ SUDO: ok
+sudo_pw= ⚠️ SUDO: requires password or not allowed
+done=✅ Report completed: {path}
diff --git a/core/utils/shflow-trust.tr.es b/core/utils/shflow-trust.tr.es
new file mode 100644
index 0000000..9e930a9
--- /dev/null
+++ b/core/utils/shflow-trust.tr.es
@@ -0,0 +1,14 @@
+start=🔍 Evaluando confianza SSH y sudo para usuario: {user}
+inventory=📁 Inventario: {path}
+report=📄 Informe: {path}
+missing_dep=❌ Requiere '{cmd}' versión Go para procesar el inventario YAML
+report_title=Informe de confianza generado por shflow-trust
+report_date=Fecha: {date}
+no_hosts=❌ No se encontraron hosts en el inventario.
+missing_ip=⚠️ Host '{host}' sin ansible_host definido
+checking=🖥️ Host: {host} ({ip})
+ssh_ok= ✅ SSH: ok
+ssh_fail= ❌ SSH: fallo de conexión
+sudo_ok= ✅ SUDO: ok
+sudo_pw= ⚠️ SUDO: requiere contraseña o no permitido
+done=✅ Informe completado: {path}
diff --git a/core/utils/vault-init.sh b/core/utils/vault-init.sh
new file mode 100755
index 0000000..3055a10
--- /dev/null
+++ b/core/utils/vault-init.sh
@@ -0,0 +1,97 @@
+#!/bin/bash
+# ShFlow Vault Initializer
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.3.0
+# Dependencies: gpg
+
+set -euo pipefail
+
+# 📁 Rutas defensivas
+PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
+VAULT_DIR="$PROJECT_ROOT/core/vault"
+VAULT_KEY="${VAULT_KEY:-$HOME/.shflow.key}"
+VAULT_PUBKEY="${VAULT_PUBKEY:-$HOME/.shflow.pub}"
+
+# 🧩 Cargar render_msg si no está disponible
+COMMON_LIB="$PROJECT_ROOT/core/lib/translate_msg.sh"
+if ! declare -f render_msg &>/dev/null; then
+ [[ -f "$COMMON_LIB" ]] && source "$COMMON_LIB"
+fi
+
+# 🌐 Cargar traducciones
+lang="${SHFLOW_LANG:-es}"
+trfile="$PROJECT_ROOT/core/utils/vault-init.tr.${lang}"
+declare -A tr
+if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+generate_key() {
+ echo "${tr[gen_key]:-🔐 Generando nueva clave simétrica...}"
+ head -c 64 /dev/urandom | base64 > "$VAULT_KEY"
+ chmod 600 "$VAULT_KEY"
+ echo "$(render_msg "${tr[key_created]}" "path=$VAULT_KEY")"
+}
+
+rotate_key() {
+ echo "${tr[rotate_start]:-🔄 Rotando clave y re-cifrando secretos...}"
+ local OLD_KEY="$VAULT_KEY.old"
+
+ cp "$VAULT_KEY" "$OLD_KEY"
+ generate_key
+
+ for file in "$VAULT_DIR"/*.gpg; do
+ key=$(basename "$file" .gpg)
+ echo "$(render_msg "${tr[recrypt]}" "key=$key")"
+ gpg --quiet --batch --yes --passphrase-file "$OLD_KEY" -d "$file" | \
+ gpg --symmetric --batch --yes --passphrase-file "$VAULT_KEY" -o "$VAULT_DIR/$key.gpg.new"
+ mv "$VAULT_DIR/$key.gpg.new" "$VAULT_DIR/$key.gpg"
+ done
+
+ echo "$(render_msg "${tr[rotate_done]}" "path=$OLD_KEY")"
+}
+
+status() {
+ echo "${tr[status_title]:-📊 Estado del Vault}"
+ echo "-------------------"
+ echo "$(render_msg "${tr[sym_key]}" "status=$( [ -f "$VAULT_KEY" ] && echo "${tr[present]}" || echo "${tr[absent]}")")"
+ echo "$(render_msg "${tr[pub_key]}" "status=$( [ -f "$VAULT_PUBKEY" ] && echo "${tr[present]}" || echo "${tr[absent]}")")"
+ echo "$(render_msg "${tr[vault_path]}" "path=$VAULT_DIR")"
+ echo "$(render_msg "${tr[secrets]}" "count=$(ls "$VAULT_DIR"/*.gpg 2>/dev/null | wc -l)")"
+ echo "$(render_msg "${tr[last_mod]}" "date=$(date -r "$VAULT_KEY" '+%Y-%m-%d %H:%M:%S' 2>/dev/null)")"
+}
+
+generate_pubkey() {
+ echo "${tr[asym_start]:-🔐 Configurando cifrado asimétrico...}"
+ echo "${tr[asym_hint]:-⚠️ Se requiere que la clave pública esté exportada previamente.}"
+ echo " gpg --export -a 'usuario@dominio' > $VAULT_PUBKEY"
+ if [ -f "$VAULT_PUBKEY" ]; then
+ echo "$(render_msg "${tr[pubkey_found]}" "path=$VAULT_PUBKEY")"
+ else
+ echo "${tr[pubkey_missing]:-❌ Clave pública no encontrada. Exporta primero con GPG.}"
+ exit 1
+ fi
+}
+
+main() {
+ case "${1:-}" in
+ --rotate)
+ [ -f "$VAULT_KEY" ] || { echo "${tr[no_key]:-❌ No existe clave actual. Ejecuta sin --rotate primero.}"; exit 1; }
+ rotate_key
+ ;;
+ --status)
+ status
+ ;;
+ --asymmetric)
+ generate_pubkey
+ ;;
+ *)
+ if [ -f "$VAULT_KEY" ]; then
+ echo "$(render_msg "${tr[key_exists]}" "path=$VAULT_KEY")"
+ else
+ generate_key
+ fi
+ ;;
+ esac
+}
+
+main "$@"
diff --git a/core/utils/vault-init.tr.en b/core/utils/vault-init.tr.en
new file mode 100644
index 0000000..8004348
--- /dev/null
+++ b/core/utils/vault-init.tr.en
@@ -0,0 +1,19 @@
+gen_key=🔐 Generating new symmetric key...
+key_created=✅ Key created at {path}
+rotate_start=🔄 Rotating key and re-encrypting secrets...
+recrypt=🔁 Re-encrypting '{key}'...
+rotate_done=✅ Rotation completed. Old key saved at {path}
+status_title=📊 Vault Status
+sym_key=🔐 Symmetric key: {status}
+pub_key=🔐 Public key: {status}
+present=✅ present
+absent=❌ absent
+vault_path=📁 Vault path: {path}
+secrets=📦 Secrets: {count}
+last_mod=🕒 Last modified: {date}
+asym_start=🔐 Setting up asymmetric encryption...
+asym_hint=⚠️ Public key must be exported beforehand.
+pubkey_found=✅ Public key found at {path}
+pubkey_missing=❌ Public key not found. Export it first using GPG.
+no_key=❌ No current key found. Run without --rotate first.
+key_exists=🔐 Key already exists at {path}
diff --git a/core/utils/vault-init.tr.es b/core/utils/vault-init.tr.es
new file mode 100644
index 0000000..8ed9644
--- /dev/null
+++ b/core/utils/vault-init.tr.es
@@ -0,0 +1,19 @@
+gen_key=🔐 Generando nueva clave simétrica...
+key_created=✅ Clave creada en {path}
+rotate_start=🔄 Rotando clave y re-cifrando secretos...
+recrypt=🔁 Re-cifrando '{key}'...
+rotate_done=✅ Rotación completada. Clave antigua guardada en {path}
+status_title=📊 Estado del Vault
+sym_key=🔐 Clave simétrica: {status}
+pub_key=🔐 Clave pública: {status}
+present=✅ presente
+absent=❌ ausente
+vault_path=📁 Ruta del vault: {path}
+secrets=📦 Secretos: {count}
+last_mod=🕒 Última modificación: {date}
+asym_start=🔐 Configurando cifrado asimétrico...
+asym_hint=⚠️ Se requiere que la clave pública esté exportada previamente.
+pubkey_found=✅ Clave pública detectada en {path}
+pubkey_missing=❌ Clave pública no encontrada. Exporta primero con GPG.
+no_key=❌ No existe clave actual. Ejecuta sin --rotate primero.
+key_exists=🔐 Clave ya existe en {path}
diff --git a/core/utils/vault_utils.sh b/core/utils/vault_utils.sh
new file mode 100755
index 0000000..702265f
--- /dev/null
+++ b/core/utils/vault_utils.sh
@@ -0,0 +1,41 @@
+#!/bin/bash
+# Utility: vault_utils
+# Description: Funciones para acceso seguro al vault de ShFlow
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.1.0
+# Dependencies: gpg
+
+VAULT_DIR="${VAULT_DIR:-core/vault}"
+VAULT_KEY="${VAULT_KEY:-$HOME/.shflow.key}"
+
+# 🧩 Cargar render_msg si no está disponible
+PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
+COMMON_LIB="$PROJECT_ROOT/core/lib/translate_msg.sh"
+if ! declare -f render_msg &>/dev/null; then
+ [[ -f "$COMMON_LIB" ]] && source "$COMMON_LIB"
+fi
+
+# 🌐 Cargar traducciones
+lang="${SHFLOW_LANG:-es}"
+trfile="$PROJECT_ROOT/core/utils/vault_utils.tr.${lang}"
+declare -A tr
+if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+get_secret() {
+ local key="$1"
+ local value
+
+ if [ ! -f "$VAULT_DIR/$key.gpg" ]; then
+ echo "$(render_msg "${tr[missing]}" "key=$key" "dir=$VAULT_DIR")"
+ return 1
+ fi
+
+ value=$(gpg --quiet --batch --yes --passphrase-file "$VAULT_KEY" -d "$VAULT_DIR/$key.gpg" 2>/dev/null)
+ if [ $? -ne 0 ]; then
+ echo "$(render_msg "${tr[decrypt_fail]}" "key=$key")"
+ return 1
+ fi
+
+ echo "$value"
+}
diff --git a/core/utils/vault_utils.tr.en b/core/utils/vault_utils.tr.en
new file mode 100644
index 0000000..413952e
--- /dev/null
+++ b/core/utils/vault_utils.tr.en
@@ -0,0 +1,2 @@
+missing=❌ [vault] Secret '{key}' not found in {dir}
+decrypt_fail=❌ [vault] Failed to decrypt '{key}'
diff --git a/core/utils/vault_utils.tr.es b/core/utils/vault_utils.tr.es
new file mode 100644
index 0000000..f81ee15
--- /dev/null
+++ b/core/utils/vault_utils.tr.es
@@ -0,0 +1,2 @@
+missing=❌ [vault] Secreto '{key}' no encontrado en {dir}
+decrypt_fail=❌ [vault] Error al descifrar '{key}'
diff --git a/core/vault/.gitignore b/core/vault/.gitignore
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/core/vault/.gitignore
diff --git a/examples/api.yaml b/examples/api.yaml
new file mode 100644
index 0000000..057dd22
--- /dev/null
+++ b/examples/api.yaml
@@ -0,0 +1,9 @@
+tasks:
+ - name: Crear producto de prueba
+ module: api
+ args:
+ method: post
+ url: https://fakestoreapi.com/products
+ body: '{"title":"Producto ShFlow","price":99.99,"description":"Generado desde módulo api.sh","image":"https://example.com/img.png","category":"electronics"}'
+ parse: json
+
diff --git a/examples/api_body_test.yaml b/examples/api_body_test.yaml
new file mode 100644
index 0000000..fe0578f
--- /dev/null
+++ b/examples/api_body_test.yaml
@@ -0,0 +1,62 @@
+tasks:
+ - name: POST simple con campos planos
+ module: api
+ args:
+ method: post
+ url: https://reqres.in/api/users
+ body: |
+ {"name":"Luis","job":"ShFlow tester"}
+ headers: x-api-key:reqres-free-v1,Content-Type:application/json
+ parse: json
+
+ - name: POST con estructura anidada
+ module: api
+ args:
+ method: post
+ url: https://httpbin.org/post
+ body: |
+ {
+ "title": "Test",
+ "tags": ["bash", "api", "flow"],
+ "meta": {
+ "author": "Luis",
+ "version": "1.0"
+ }
+ }
+ headers: Content-Type:application/json
+ parse: json
+
+ - name: POST con cuerpo vacío
+ module: api
+ args:
+ method: post
+ url: https://httpbin.org/post
+ body: ""
+ headers: Content-Type:application/json
+ parse: json
+
+ - name: POST con caracteres especiales
+ module: api
+ args:
+ method: post
+ url: https://httpbin.org/post
+ body: |
+ {"mensaje":"¡Hola desde ShFlow! 🎉 Ñandú con tilde y ñ"}
+ headers: Content-Type:application/json
+ parse: json
+
+ - name: POST con array de objetos
+ module: api
+ args:
+ method: post
+ url: https://httpbin.org/post
+ body: |
+ {
+ "usuarios": [
+ {"nombre": "Luis", "rol": "admin"},
+ {"nombre": "Ana", "rol": "tester"}
+ ]
+ }
+ headers: Content-Type:application/json
+ parse: json
+
diff --git a/examples/api_get-json.yaml b/examples/api_get-json.yaml
new file mode 100644
index 0000000..eb3619c
--- /dev/null
+++ b/examples/api_get-json.yaml
@@ -0,0 +1,7 @@
+tasks:
+ - name: Obtener producto fake
+ module: api
+ args:
+ method: get
+ url: https://fakestoreapi.com/products/1
+ parse: json
diff --git a/examples/api_post-json.yaml b/examples/api_post-json.yaml
new file mode 100644
index 0000000..20710dd
--- /dev/null
+++ b/examples/api_post-json.yaml
@@ -0,0 +1,8 @@
+- name: Crear usuario
+ module: api
+ args:
+ method: post
+ url: https://api.example.com/users
+ headers: Content-Type:application/json
+ body: '{"name":"Luis","role":"admin"}'
+ parse: json
diff --git a/examples/api_post.yaml b/examples/api_post.yaml
new file mode 100644
index 0000000..87c640d
--- /dev/null
+++ b/examples/api_post.yaml
@@ -0,0 +1,10 @@
+tasks:
+ - name: Crear producto de prueba
+ module: api
+ args:
+ method: post
+ url: https://fakestoreapi.com/products
+body: |
+ {"title":"Producto ShFlow","price":99.99,"description":"Generado desde módulo api.sh","image":"https://example.com/img.png","category":"electronics"}
+ parse: json
+
diff --git a/examples/api_reqres.yaml b/examples/api_reqres.yaml
new file mode 100644
index 0000000..d261ff8
--- /dev/null
+++ b/examples/api_reqres.yaml
@@ -0,0 +1,11 @@
+tasks:
+ - name: Crear usuario de prueba
+ module: api
+ args:
+ method: post
+ url: https://reqres.in/api/users
+ body: |
+ {"name":"Luis","job":"ShFlow tester"}
+ headers: x-api-key:reqres-free-v1,Content-Type:application/json
+ parse: json
+
diff --git a/examples/api_soap-xml.yaml b/examples/api_soap-xml.yaml
new file mode 100644
index 0000000..7517a65
--- /dev/null
+++ b/examples/api_soap-xml.yaml
@@ -0,0 +1,14 @@
+tasks:
+ - name: Consulta SOAP de prueba
+ module: api
+ args:
+ method: soap
+ url: http://demo1144442.mockable.io/
+ headers: Content-Type:text/xml
+ body: |
+ <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
+ <soapenv:Body>
+ <ns:getStatus xmlns:ns="http://demo1144442.mockable.io/"/>
+ </soapenv:Body>
+ </soapenv:Envelope>
+ parse: xml
diff --git a/examples/apt-update.yaml b/examples/apt-update.yaml
new file mode 100644
index 0000000..7663dcd
--- /dev/null
+++ b/examples/apt-update.yaml
@@ -0,0 +1,13 @@
+tasks:
+ - name: Actualizar datos del Repo
+ module: run
+ args:
+ command: "apt-get -y update"
+ become: true
+
+ - name: Actualizar SO
+ module: run
+ args:
+ command: "apt-get -y upgrade"
+ become: true
+
diff --git a/examples/archive-compress-tar.gz.yaml b/examples/archive-compress-tar.gz.yaml
new file mode 100644
index 0000000..c52ab8c
--- /dev/null
+++ b/examples/archive-compress-tar.gz.yaml
@@ -0,0 +1,9 @@
+tasks:
+ - name: Comprimir logs
+ module: archive
+ args:
+ action: compress
+ format: tar
+ files: /var/log/boot.log,/var/log/lastlog
+ output: /tmp/logs.tar.gz
+ become: true
diff --git a/examples/archive-decompress-zip.yaml b/examples/archive-decompress-zip.yaml
new file mode 100644
index 0000000..fa6d436
--- /dev/null
+++ b/examples/archive-decompress-zip.yaml
@@ -0,0 +1,8 @@
+tasks:
+ - name: Descomprimir ZIP
+ module: archive
+ args:
+ action: decompress
+ format: zip
+ archive: /tmp/project.zip
+ dest: /tmp/destino/project
diff --git a/examples/archive-extract-bootlog.yaml b/examples/archive-extract-bootlog.yaml
new file mode 100644
index 0000000..7816a86
--- /dev/null
+++ b/examples/archive-extract-bootlog.yaml
@@ -0,0 +1,11 @@
+tasks:
+ - name: Extraer solo boot.log desde logs.tar.gz
+ module: archive
+ args:
+ action: extract
+ format: tar
+ archive: /tmp/logs.tar.gz
+ dest: /tmp/extraccion/
+ files: var/log/boot.log
+ become: true
+
diff --git a/examples/archive-extract-tar.gz.yaml b/examples/archive-extract-tar.gz.yaml
new file mode 100644
index 0000000..3e8863b
--- /dev/null
+++ b/examples/archive-extract-tar.gz.yaml
@@ -0,0 +1,9 @@
+tasks:
+ - name: Extraer backup
+ module: archive
+ args:
+ action: extract
+ format: tar
+ archive: /tmp/logs.tar.gz
+ dest: /tmp/restore
+ become: true
diff --git a/examples/basic_conditions.yaml b/examples/basic_conditions.yaml
new file mode 100644
index 0000000..655b800
--- /dev/null
+++ b/examples/basic_conditions.yaml
@@ -0,0 +1,15 @@
+hosts: localhost
+parallelism: false
+
+tasks:
+ - name: Pausa si el host es localhost
+ module: wait
+ args:
+ seconds: 1
+ condition: '[ "{{ name }}" = "localhost" ]'
+
+ - name: Pausa solo si existe el archivo /tmp/flag.txt
+ module: wait
+ args:
+ seconds: 2
+ condition: 'test -f /tmp/flag.txt'
diff --git a/examples/block-in-file.yaml b/examples/block-in-file.yaml
new file mode 100644
index 0000000..2cdd7eb
--- /dev/null
+++ b/examples/block-in-file.yaml
@@ -0,0 +1,10 @@
+- name: Añadir bloque de configuración a NGINX
+ module: blockinfile
+ args:
+ path: /etc/nginx/nginx.conf
+ block: |
+ server_tokens off;
+ keepalive_timeout 65;
+ marker: NGINX_CONF
+ backup: true
+ become: true
diff --git a/examples/clone-and-configure.yaml b/examples/clone-and-configure.yaml
new file mode 100644
index 0000000..5de2669
--- /dev/null
+++ b/examples/clone-and-configure.yaml
@@ -0,0 +1,24 @@
+tasks:
+ - name: Clonar repo de configuración
+ module: git
+ args:
+ action: clone
+ repo: "https://github.com/luisgulo/configs.git"
+ dest: "/opt/configs"
+ become: true
+
+ - name: Copiar archivo de configuración
+ module: git
+ args:
+ action: fetch-file
+ repo: "https://github.com/luisgulo/configs.git"
+ branch: "main"
+ file_path: "nginx/nginx.conf"
+ dest: "/etc/nginx/nginx.conf"
+ become: true
+
+ - name: Reiniciar nginx
+ module: run
+ args:
+ command: "systemctl restart nginx"
+ become: true
diff --git a/examples/cron_root.yaml b/examples/cron_root.yaml
new file mode 100644
index 0000000..e54f00f
--- /dev/null
+++ b/examples/cron_root.yaml
@@ -0,0 +1,46 @@
+hosts: localhost
+parallelism: false
+
+tasks:
+ - name: Crear cron ver_df cada hora
+ module: cron
+ args:
+ alias: ver_df
+ user: root
+ state: present
+ schedule: "0 * * * *"
+ command: "df -h > /tmp/filesize.txt"
+
+ - name: Listar cron de root (tras creación)
+ module: cron
+ args:
+ user: root
+ state: list
+
+ - name: Modificar cron ver_df a cada 3 horas
+ module: cron
+ args:
+ alias: ver_df
+ user: root
+ state: present
+ schedule: "0 */3 * * *"
+ command: "df -h > /tmp/filesize.txt"
+
+ - name: Listar cron de root (tras modificación)
+ module: cron
+ args:
+ user: root
+ state: list
+
+ - name: Eliminar cron ver_df
+ module: cron
+ args:
+ alias: ver_df
+ user: root
+ state: absent
+
+ - name: Listar cron de root (tras eliminación)
+ module: cron
+ args:
+ user: root
+ state: list
diff --git a/examples/deploy-nginx.yaml b/examples/deploy-nginx.yaml
new file mode 100644
index 0000000..d2c9c31
--- /dev/null
+++ b/examples/deploy-nginx.yaml
@@ -0,0 +1,24 @@
+tasks:
+ - name: Crear directorio web
+ module: file
+ args:
+ path: "/var/www/html"
+ state: present
+ type: directory
+ mode: "0755"
+ become: true
+
+ - name: Ejecutar contenedor nginx
+ module: docker
+ args:
+ action: present
+ name: nginx_web
+ image: nginx:latest
+ become: true
+
+ - name: Habilitar servicio docker
+ module: service
+ args:
+ name: docker
+ state: enable
+ become: true
diff --git a/examples/docker_and_ldapsearch.yaml b/examples/docker_and_ldapsearch.yaml
new file mode 100644
index 0000000..50cf48e
--- /dev/null
+++ b/examples/docker_and_ldapsearch.yaml
@@ -0,0 +1,31 @@
+tasks:
+ - name: Lanzar contenedor LDAP
+ module: docker
+ args:
+ action: present
+ name: test-ldap
+ image: osixia/openldap:latest
+ run_args: "-p 389:389 -p 636:636 -e LDAP_ORGANISATION='Test Org' -e LDAP_DOMAIN='test.local' -e LDAP_ADMIN_PASSWORD='secreta'"
+
+ - name: Esperar 5 segundos para que LDAP esté listo
+ module: wait
+ args:
+ seconds: 5
+
+ - name: Buscar usuarios en OpenLDAP
+ module: ldap_openldap
+ args:
+ state: search
+ server: ldap://localhost
+ port: 389
+ base_dn: "dc=test,dc=local"
+ filter: "(objectClass=*)"
+ attributes: "cn,mail,uid"
+ bind_dn: "cn=admin,dc=test,dc=local"
+ password: "secreta"
+
+ #- name: Eliminar LDAP
+ # module: docker
+ # args:
+ # action: absent
+ # name: test-ldap
diff --git a/examples/docker_build-shflow.yaml b/examples/docker_build-shflow.yaml
new file mode 100644
index 0000000..f72ebd3
--- /dev/null
+++ b/examples/docker_build-shflow.yaml
@@ -0,0 +1,8 @@
+tasks:
+ - name: Construir imagen test-shflow desde debian:trixie
+ module: docker
+ args:
+ action: build
+ image: test-shflow
+ path: /tmp/shflow-build/
+ become: true
diff --git a/examples/docker_hello-world.yaml b/examples/docker_hello-world.yaml
new file mode 100644
index 0000000..cabc9d6
--- /dev/null
+++ b/examples/docker_hello-world.yaml
@@ -0,0 +1,8 @@
+tasks:
+ - name: Probar imagen hello-world
+ module: docker
+ args:
+ action: present
+ name: test-hello
+ image: hello-world
+ detach: false
diff --git a/examples/download.yaml b/examples/download.yaml
new file mode 100644
index 0000000..ce1276e
--- /dev/null
+++ b/examples/download.yaml
@@ -0,0 +1,9 @@
+tasks:
+ - name: Descargar binario de servicio
+ module: download
+ args:
+ url: https://example.com/binario.tar.gz
+ dest: /tmp/manual.pdf
+ proxy: http://proxy.local:3128
+ continue: true
+ become: true
diff --git a/examples/echo_capture.yaml b/examples/echo_capture.yaml
new file mode 100644
index 0000000..fa44030
--- /dev/null
+++ b/examples/echo_capture.yaml
@@ -0,0 +1,32 @@
+parallelism: false
+hosts: localhost
+
+tasks:
+ - name: Ejecutar comando simple
+ module: run
+ args:
+ command: "ls -L /tmpAAA"
+ become: false
+ capture_log: ls_resultado
+ capture_err: ls_error
+
+ - name: Mostrar capture_log con modulo echo
+ module: echo
+ args:
+ message: "{{ ls_resultado }}"
+ become: false
+ condition: "[ {{ ls_error }} -eq 0 ]"
+
+ - name: Mostrar capture_err con modulo echo
+ module: echo
+ args:
+ message: "{{ ls_error }}"
+ become: false
+
+ - name: Prueba sustitucion en modulo echo
+ module: echo
+ args:
+ variableA: "Texto de variable A"
+ variableB: "Texto de variable B"
+ message: "Mostramos A: {{ variableA }} y B: {{ variableB }}"
+ become: false
diff --git a/examples/euvd_check.yaml b/examples/euvd_check.yaml
new file mode 100644
index 0000000..5a52dc4
--- /dev/null
+++ b/examples/euvd_check.yaml
@@ -0,0 +1,17 @@
+hosts: servidor_pruebas
+tasks:
+ - name: Verificar vulnerabilidad openssl EUVD
+ module: euvd_check
+ args:
+ state: check
+ enisa_id: EUVD-2025-31120
+ package: openssl
+ become: true
+
+ - name: Verificar vulnerabilidad sudo EUVD
+ module: euvd_check
+ args:
+ state: check
+ enisa_id: EUVD-2025-19673
+ package: sudo
+ become: true
diff --git a/examples/facts_key-value.yaml b/examples/facts_key-value.yaml
new file mode 100644
index 0000000..8e18977
--- /dev/null
+++ b/examples/facts_key-value.yaml
@@ -0,0 +1,6 @@
+tasks:
+ - name: IPs del host
+ module: facts
+ args:
+ field: ip_addresses
+ format: kv
diff --git a/examples/facts_markdown.yaml b/examples/facts_markdown.yaml
new file mode 100644
index 0000000..83bd82f
--- /dev/null
+++ b/examples/facts_markdown.yaml
@@ -0,0 +1,8 @@
+tasks:
+ - name: Inventario de servidores
+ module: facts
+ args:
+ format: md
+ output: /tmp/informe.md
+ append: true
+ host_label: servidor-01
diff --git a/examples/file_read_apache-config.yaml b/examples/file_read_apache-config.yaml
new file mode 100644
index 0000000..d402c51
--- /dev/null
+++ b/examples/file_read_apache-config.yaml
@@ -0,0 +1,10 @@
+hosts: localhost
+parallelism: false
+
+tasks:
+ - name: Leer configuración de Apache sin comentarios
+ module: file_read
+ args:
+ path: "/etc/apache2/apache2.conf"
+ grep: "^[^#]"
+ become: true
diff --git a/examples/fs-copy.yaml b/examples/fs-copy.yaml
new file mode 100644
index 0000000..83dffbd
--- /dev/null
+++ b/examples/fs-copy.yaml
@@ -0,0 +1,6 @@
+- name: Copiar config
+ module: fs
+ args:
+ action: copy
+ src: /etc/nginx/nginx.conf
+ dest: /tmp/nginx.conf.bak
diff --git a/examples/fs-delete-multiple.yaml b/examples/fs-delete-multiple.yaml
new file mode 100644
index 0000000..37d90b7
--- /dev/null
+++ b/examples/fs-delete-multiple.yaml
@@ -0,0 +1,5 @@
+- name: Eliminar temporales
+ module: fs
+ args:
+ action: delete
+ files: /tmp/debug.log,/tmp/test.out
diff --git a/examples/fs-delete.yaml b/examples/fs-delete.yaml
new file mode 100644
index 0000000..48bc035
--- /dev/null
+++ b/examples/fs-delete.yaml
@@ -0,0 +1,5 @@
+- name: Eliminar temporal
+ module: fs
+ args:
+ action: delete
+ path: /tmp/debug.log
diff --git a/examples/fs-move-multiple.yaml b/examples/fs-move-multiple.yaml
new file mode 100644
index 0000000..45c09f0
--- /dev/null
+++ b/examples/fs-move-multiple.yaml
@@ -0,0 +1,6 @@
+- name: Mover logs antiguos
+ module: fs
+ args:
+ action: move
+ files: /var/log/app1.log,/var/log/app2.log
+ dest: /tmp/logs
diff --git a/examples/fs-move.yaml b/examples/fs-move.yaml
new file mode 100644
index 0000000..34144dd
--- /dev/null
+++ b/examples/fs-move.yaml
@@ -0,0 +1,6 @@
+- name: Mover log
+ module: fs
+ args:
+ action: move
+ src: /var/log/app.log
+ dest: /tmp/app.log
diff --git a/examples/fs-rename.yaml b/examples/fs-rename.yaml
new file mode 100644
index 0000000..ef5eb20
--- /dev/null
+++ b/examples/fs-rename.yaml
@@ -0,0 +1,6 @@
+- name: Renombrar backup
+ module: fs
+ args:
+ action: rename
+ src: /home/user/backup.tar.gz
+ dest: /home/user/backup_old.tar.gz
diff --git a/examples/fs-truncate-multiple.yaml b/examples/fs-truncate-multiple.yaml
new file mode 100644
index 0000000..033418f
--- /dev/null
+++ b/examples/fs-truncate-multiple.yaml
@@ -0,0 +1,5 @@
+- name: Vaciar logs
+ module: fs
+ args:
+ action: truncate
+ files: /var/log/app1.log,/var/log/app2.log
diff --git a/examples/fs-truncate.yaml b/examples/fs-truncate.yaml
new file mode 100644
index 0000000..986b8b9
--- /dev/null
+++ b/examples/fs-truncate.yaml
@@ -0,0 +1,5 @@
+- name: Vaciar log
+ module: fs
+ args:
+ action: truncate
+ path: /var/log/app.log
diff --git a/examples/full-stack.yaml b/examples/full-stack.yaml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/examples/full-stack.yaml
diff --git a/examples/groups_minimal.yaml b/examples/groups_minimal.yaml
new file mode 100644
index 0000000..882ad61
--- /dev/null
+++ b/examples/groups_minimal.yaml
@@ -0,0 +1,6 @@
+tasks:
+ - name: Crear grupo de servicio
+ module: groups
+ args:
+ groupname: servicio
+ become: true
diff --git a/examples/install-tools.yaml b/examples/install-tools.yaml
new file mode 100644
index 0000000..98d4ecf
--- /dev/null
+++ b/examples/install-tools.yaml
@@ -0,0 +1,14 @@
+tasks:
+ - name: Instalar curl
+ module: package
+ args:
+ name: curl
+ state: present
+ become: true
+
+ - name: Instalar vim
+ module: package
+ args:
+ name: vim
+ state: present
+ become: true
diff --git a/examples/ldap_search.yaml b/examples/ldap_search.yaml
new file mode 100644
index 0000000..11d3bab
--- /dev/null
+++ b/examples/ldap_search.yaml
@@ -0,0 +1,24 @@
+tasks:
+ - name: Buscar usuarios en OpenLDAP
+ module: ldap_openldap
+ args:
+ state: search
+ server: ldap://ldap.example.com
+ port: 389
+ base_dn: "dc=example,dc=com"
+ filter: "(objectClass=person)"
+ attributes: "cn,mail,uid"
+ bind_dn: "cn=admin,dc=example,dc=com"
+ password: "secreta"
+
+ - name: Buscar usuarios en Active Directory
+ module: ldap_ad
+ args:
+ state: search
+ server: ldap://ad.example.local
+ port: 389
+ base_dn: "dc=example,dc=local"
+ filter: "(sAMAccountName=luisgulo)"
+ attributes: "cn,mail,sAMAccountName"
+ bind_dn: "CN=Administrador,CN=Users,DC=example,DC=local"
+ password: "secreta"
diff --git a/examples/line-in-file.yaml b/examples/line-in-file.yaml
new file mode 100644
index 0000000..3a874ba
--- /dev/null
+++ b/examples/line-in-file.yaml
@@ -0,0 +1,8 @@
+- name: Asegurar configuración de NGINX
+ module: lineinfile
+ args:
+ path: /etc/nginx/nginx.conf
+ line: 'client_max_body_size 20M;'
+ regexp: '^client_max_body_size'
+ backup: true
+ become: true
diff --git a/examples/log-shflow.yaml b/examples/log-shflow.yaml
new file mode 100644
index 0000000..0166271
--- /dev/null
+++ b/examples/log-shflow.yaml
@@ -0,0 +1,15 @@
+tasks:
+ - name: Crear directorio temporal
+ module: file
+ args:
+ path: "/tmp/shflow"
+ state: present
+ type: directory
+ mode: "0755"
+ become: true
+
+ - name: Mostrar fecha en remoto
+ module: run
+ args:
+ command: "date"
+ become: false
diff --git a/examples/lookup_password.yaml b/examples/lookup_password.yaml
new file mode 100644
index 0000000..eb5e3b8
--- /dev/null
+++ b/examples/lookup_password.yaml
@@ -0,0 +1,18 @@
+hosts: localhost
+parallelism: false
+
+# Nota: Crear clave con: shflow-vault add smtp_pass
+vars:
+ clave_email: "{{ vault('smtp_pass') }}"
+
+tasks:
+ - name: Mostrar clave recogida del vault
+ module: echo
+ args:
+ message: "🔐 Secreto descifrado: {{ clave_email }}"
+
+ - name: Mostrar valor directamente desde el vault
+ module: echo
+ args:
+ message: "Valor directo del vault: {{ vault('smtp_pass') }}"
+
diff --git a/examples/loop.yaml b/examples/loop.yaml
new file mode 100644
index 0000000..474eb38
--- /dev/null
+++ b/examples/loop.yaml
@@ -0,0 +1,9 @@
+- name: Crear usuarios
+ module: loop
+ args:
+ items: alice,bob,carol
+ module: useradd
+ args:
+ name: {{item}}
+ shell: /bin/bash
+ become: true
diff --git a/examples/loop_cartesian-values.yaml b/examples/loop_cartesian-values.yaml
new file mode 100644
index 0000000..bfb2c4b
--- /dev/null
+++ b/examples/loop_cartesian-values.yaml
@@ -0,0 +1,9 @@
+- name: Asignar permisos cruzados
+ module: loop
+ args:
+ items: alice,bob
+ secondary: read,write
+ module: permission
+ args:
+ user: {{item}}
+ mode: {{secondary_item}}
diff --git a/examples/loop_fail-fast.yaml b/examples/loop_fail-fast.yaml
new file mode 100644
index 0000000..200f1ca
--- /dev/null
+++ b/examples/loop_fail-fast.yaml
@@ -0,0 +1,10 @@
+- name: Crear usuarios sin detenerse ante errores
+ module: loop
+ args:
+ items: alice,bob,carol
+ module: useradd
+ fail_fast: false
+ args:
+ name: {{item}}
+ shell: /bin/bash
+ become: true
diff --git a/examples/loop_key-value.yaml b/examples/loop_key-value.yaml
new file mode 100644
index 0000000..d1d54de
--- /dev/null
+++ b/examples/loop_key-value.yaml
@@ -0,0 +1,10 @@
+- name: Asignar usuarios a grupos
+ module: loop
+ args:
+ items: alice:sudo,bob:docker,carol:admin
+ module: useradd
+ args:
+ name: {{item_key}}
+ groups: {{item_value}}
+ shell: /bin/bash
+ become: true
diff --git a/examples/loop_matrix.yaml b/examples/loop_matrix.yaml
new file mode 100644
index 0000000..35253db
--- /dev/null
+++ b/examples/loop_matrix.yaml
@@ -0,0 +1,14 @@
+hosts: localhost
+parallelism: false
+
+tasks:
+ - name: Probar conectividad entre múltiples orígenes y destinos
+ module: loop
+ args:
+ items: "equipo1:192.168.1.1,equipo2:10.0.0.1"
+ secondary: "8.8.8.8,1.1.1.1"
+ module: ping
+ count: 2
+ timeout: 3
+ target: "{{secondary_item}}"
+ become: true
diff --git a/examples/ls.yaml b/examples/ls.yaml
new file mode 100644
index 0000000..e9ba61e
--- /dev/null
+++ b/examples/ls.yaml
@@ -0,0 +1,13 @@
+tasks:
+ - name: Listar directorios tmp
+ module: run
+ args:
+ command: "ls -l /tmp"
+ become: false
+
+ - name: Listar directorios /root
+ module: run
+ args:
+ command: "ls -l /root"
+ become: true
+
diff --git a/examples/minimal.yaml b/examples/minimal.yaml
new file mode 100644
index 0000000..fb04e0f
--- /dev/null
+++ b/examples/minimal.yaml
@@ -0,0 +1,15 @@
+tasks:
+ - name: Crear directorio de logs
+ module: file
+ args:
+ path: "/var/log/shflow"
+ state: present
+ type: directory
+ mode: "0755"
+ become: true
+
+ - name: Mostrar fecha en remoto
+ module: run
+ args:
+ command: "date"
+ become: false
diff --git a/examples/openssl_autogen.yaml b/examples/openssl_autogen.yaml
new file mode 100644
index 0000000..8c02e70
--- /dev/null
+++ b/examples/openssl_autogen.yaml
@@ -0,0 +1,46 @@
+hosts: localhost
+parallelism: false
+
+tasks:
+ - name: Generar certificado autofirmado en /tmp
+ module: run
+ args:
+ command: |
+ openssl req -x509 -newkey rsa:2048 -keyout /tmp/test.key -out /tmp/test.crt \
+ -days 365 -nodes -subj "/CN=example.com" && \
+ openssl pkcs12 -export -out /tmp/certificado.pfx \
+ -inkey /tmp/test.key -in /tmp/test.crt \
+ -name "Certificado de Prueba" -password pass:secreta
+ become: false
+
+ - name: Convertir PFX a PEM
+ module: openssl
+ args:
+ state: convert
+ src: /tmp/certificado.pfx
+ dest: /tmp/certificado.pem
+ format: pem
+ password: "secreta"
+
+ - name: Inspeccionar certificado convertido
+ module: openssl
+ args:
+ state: inspect
+ src: /tmp/certificado.pem
+
+ - name: Instalar certificado como CA confiable
+ module: openssl
+ args:
+ state: trust
+ src: /tmp/certificado.pem
+ alias: mi_certificado
+ trust_path: /usr/local/share/ca-certificates/
+ become: true
+
+ - name: Eliminar certificado como CA
+ module: openssl
+ args:
+ state: untrust
+ alias: mi_certificado
+ trust_path: /usr/local/share/ca-certificates/
+ become: true
diff --git a/examples/ping.yaml b/examples/ping.yaml
new file mode 100644
index 0000000..b541256
--- /dev/null
+++ b/examples/ping.yaml
@@ -0,0 +1,7 @@
+tasks:
+ - name: Verificar conectividad con servidor
+ module: ping
+ args:
+ count: 3
+ timeout: 5
+ become: false
diff --git a/examples/remote-ping.yaml b/examples/remote-ping.yaml
new file mode 100644
index 0000000..075c628
--- /dev/null
+++ b/examples/remote-ping.yaml
@@ -0,0 +1,8 @@
+tasks:
+ - name: Verificar acceso a gateway desde web01
+ module: ping
+ args:
+ target: 192.168.1.12
+ count: 4
+ timeout: 5
+ become: true
diff --git a/examples/remote_user.yaml b/examples/remote_user.yaml
new file mode 100644
index 0000000..ffda478
--- /dev/null
+++ b/examples/remote_user.yaml
@@ -0,0 +1,9 @@
+vars:
+ remote_user: other
+
+tasks:
+ - name: listar directorio de root desde el usuario "other" que tiene sudo
+ module: run
+ args:
+ command: "ls -l /root"
+ become: true
diff --git a/examples/replace-string.yaml b/examples/replace-string.yaml
new file mode 100644
index 0000000..97f8c9e
--- /dev/null
+++ b/examples/replace-string.yaml
@@ -0,0 +1,8 @@
+- name: Reemplazar puerto en archivo de configuración
+ module: replace
+ args:
+ path: /etc/nginx/sites-available/default
+ regexp: 'listen\s+80'
+ replace: 'listen 8080'
+ backup: true
+ become: true
diff --git a/examples/smtp_send_test.yaml b/examples/smtp_send_test.yaml
new file mode 100644
index 0000000..7c350f1
--- /dev/null
+++ b/examples/smtp_send_test.yaml
@@ -0,0 +1,12 @@
+tasks:
+ - name: Enviar correo de prueba
+ module: smtp_send
+ args:
+ smtp_server: smtp.example.com
+ smtp_port: 587
+ smtp_user: usuario@example.com
+ smtp_pass: ClaveSuperSecreta123
+ from: usuario@example.com
+ to: destino@correo.com
+ subject: Prueba desde ShFlow
+ body: Este es un correo de prueba enviado desde el módulo smtp_send.
diff --git a/examples/template.yaml b/examples/template.yaml
new file mode 100644
index 0000000..99d7fb2
--- /dev/null
+++ b/examples/template.yaml
@@ -0,0 +1,8 @@
+- name: Generar configuración de NGINX
+ module: template
+ args:
+ src: nginx.conf.tmpl
+ dest: /etc/nginx/nginx.conf
+ port: 8080
+ user: www-data
+ become: true
diff --git a/examples/update_datetime.yaml b/examples/update_datetime.yaml
new file mode 100644
index 0000000..3dde6a1
--- /dev/null
+++ b/examples/update_datetime.yaml
@@ -0,0 +1,43 @@
+parallelism: false
+
+tasks:
+ - name: Verificar disponibilidad de chronyc
+ module: run
+ args:
+ command: "command -v chronyc >/dev/null"
+ capture_err: chrony_err
+
+ - name: Abortar si chronyc no está disponible
+ module: run
+ args:
+ command: "echo '{{ chrony_err }}' | grep -q '1' && echo '❌ Chrony no está instalado. No se puede ajustar la hora.' && exit 1 || true"
+
+ - name: Sincronizar hora con Chrony
+ module: run
+ args:
+ command: "chronyc -a makestep"
+ become: true
+ capture_log: sync_result
+ capture_err: sync_err
+
+ - name: Mostrar valores de sincronizacion
+ module: echo
+ args:
+ message: "Cod.Err: {{ sync_err }} - {{ sync_result }}"
+
+ - name: Validar sincronización
+ module: run
+ args:
+ command: "echo '{{ sync_result }}' | grep -q '200 OK' && echo '✅ Hora sincronizada correctamente' || echo '❌ Fallo en sincronización'"
+
+
+ - name: Verificar estado del reloj
+ module: run
+ args:
+ command: "timedatectl status"
+
+ - name: ls en root
+ module: run
+ args:
+ command: "ls -l /root"
+ become: true
diff --git a/examples/update_so.yaml b/examples/update_so.yaml
new file mode 100644
index 0000000..5ed8432
--- /dev/null
+++ b/examples/update_so.yaml
@@ -0,0 +1,10 @@
+parallelism: false
+
+tasks:
+ - name: Actualizar sistema completo
+ module: package
+ args:
+ state: system-update
+ update_type: full
+ become: true
+
diff --git a/examples/update_so_security.yaml b/examples/update_so_security.yaml
new file mode 100644
index 0000000..09c0054
--- /dev/null
+++ b/examples/update_so_security.yaml
@@ -0,0 +1,8 @@
+tasks:
+ - name: Aplicar parches de seguridad (solo RPM)
+ module: package
+ args:
+ state: system-update
+ update_type: security
+ become: true
+
diff --git a/examples/user_create.yaml b/examples/user_create.yaml
new file mode 100644
index 0000000..e0bf6be
--- /dev/null
+++ b/examples/user_create.yaml
@@ -0,0 +1,12 @@
+hosts: localhost
+parallelism: false
+tasks:
+ - name: Crear usuario de servicio
+ module: users
+ args:
+ state: create
+ username: servicio
+ home: /home/servicio
+ groups: docker
+ shell: /usr/sbin/nologin
+ become: true \ No newline at end of file
diff --git a/examples/user_delete.yaml b/examples/user_delete.yaml
new file mode 100644
index 0000000..db5cc19
--- /dev/null
+++ b/examples/user_delete.yaml
@@ -0,0 +1,12 @@
+hosts: localhost
+parallelism: false
+tasks:
+ - name: Eliminar usuario de servicio
+ module: users
+ args:
+ state: absent
+ username: servicio
+ home: /home/servicio
+ groups: docker
+ shell: /bin/bash
+ become: true
diff --git a/examples/user_modify.yaml b/examples/user_modify.yaml
new file mode 100644
index 0000000..35e629c
--- /dev/null
+++ b/examples/user_modify.yaml
@@ -0,0 +1,12 @@
+hosts: localhost
+parallelism: false
+tasks:
+ - name: Modificar shell de servicio
+ module: users
+ args:
+ state: modify
+ username: servicio
+ home: /home/servicio
+ groups: docker
+ shell: /bin/bash
+ become: true
diff --git a/examples/user_test.yaml b/examples/user_test.yaml
new file mode 100644
index 0000000..77d278e
--- /dev/null
+++ b/examples/user_test.yaml
@@ -0,0 +1,32 @@
+hosts: localhost
+parallelism: false
+tasks:
+ - name: Crear usuario de servicio
+ module: users
+ args:
+ state: create
+ username: servicio
+ home: /home/servicio
+ groups: docker
+ shell: /usr/sbin/nologin
+ become: true
+
+ - name: Modificar shell de servicio
+ module: users
+ args:
+ state: modify
+ username: servicio
+ home: /home/servicio
+ groups: docker
+ shell: /bin/bash
+ become: true
+
+ - name: Eliminar usuario de servicio
+ module: users
+ args:
+ state: absent
+ username: servicio
+ home: /home/servicio
+ groups: docker
+ shell: /bin/bash
+ become: true
diff --git a/examples/useradd.yaml b/examples/useradd.yaml
new file mode 100644
index 0000000..0f86ba3
--- /dev/null
+++ b/examples/useradd.yaml
@@ -0,0 +1,8 @@
+- name: Crear usuario técnico
+ module: useradd
+ args:
+ name: devops
+ shell: /bin/bash
+ home: /srv/devops
+ groups: sudo,docker
+ become: true
diff --git a/examples/vault-demo.yaml b/examples/vault-demo.yaml
new file mode 100644
index 0000000..44d3860
--- /dev/null
+++ b/examples/vault-demo.yaml
@@ -0,0 +1,7 @@
+tasks:
+ - name: Acceder a API con token seguro
+ module: run
+ args:
+ command: "curl -H \"Authorization: Bearer \$TOKEN\" https://api.example.com/status"
+ vault_key: "api_token"
+ become: false
diff --git a/examples/vault-sync.yaml b/examples/vault-sync.yaml
new file mode 100644
index 0000000..6335379
--- /dev/null
+++ b/examples/vault-sync.yaml
@@ -0,0 +1,23 @@
+tasks:
+ - name: Enviar secreto 'api_token' al host remoto
+ module: vault-remote
+ args:
+ action: push
+ key: api_token
+ remote_path: "/etc/shflow/vault"
+ become: true
+
+ - name: Recuperar secreto 'db_password' desde el host remoto
+ module: vault-remote
+ args:
+ action: pull
+ key: db_password
+ remote_path: "/etc/shflow/vault"
+ become: true
+
+ - name: Sincronizar todos los secretos locales al host remoto
+ module: vault-remote
+ args:
+ action: sync
+ remote_path: "/etc/shflow/vault"
+ become: true
diff --git a/examples/wait.yaml b/examples/wait.yaml
new file mode 100644
index 0000000..87c9bb4
--- /dev/null
+++ b/examples/wait.yaml
@@ -0,0 +1,5 @@
+tasks:
+ - name: Pausa breve
+ module: wait
+ args:
+ seconds: 1.5
diff --git a/examples/winremote_check_test.yaml b/examples/winremote_check_test.yaml
new file mode 100644
index 0000000..518e73d
--- /dev/null
+++ b/examples/winremote_check_test.yaml
@@ -0,0 +1,11 @@
+# Playbook: winremote_check_test.yml
+# Descripción: Verifica acceso remoto a un equipo Windows mediante SSH y ejecución de PowerShell
+# Autor: Luis GuLo
+
+tasks:
+ - name: Verificar acceso remoto a Windows
+ module: winremote_check
+ args:
+ winuser: guillermo
+ winpassword: "{{ vault('winpass') }}"
+ port: 2222
diff --git a/examples/winremote_detect.yaml b/examples/winremote_detect.yaml
new file mode 100644
index 0000000..4eca690
--- /dev/null
+++ b/examples/winremote_detect.yaml
@@ -0,0 +1,8 @@
+tasks:
+ - name: Detectar protocolos remotos en equipo Windows
+ module: winremote_detect
+ args:
+ ssh_port: 22
+ winrm_port: 5985
+
+
diff --git a/examples/winremote_exec.yaml b/examples/winremote_exec.yaml
new file mode 100644
index 0000000..967f122
--- /dev/null
+++ b/examples/winremote_exec.yaml
@@ -0,0 +1,10 @@
+tasks:
+
+ - name: Ejecutar comando remoto en Windows vía SSH
+ module: winremote_exec
+ args:
+ winuser: guillermo
+ winpassword: puertas
+ port: 2222
+ #command: "Get-Process | Where-Object {$_.CPU -gt 1}"
+ command: "whoami"
diff --git a/examples/winremote_exec_winrm.yml b/examples/winremote_exec_winrm.yml
new file mode 100644
index 0000000..28e6afb
--- /dev/null
+++ b/examples/winremote_exec_winrm.yml
@@ -0,0 +1,9 @@
+tasks:
+
+ - name: Ejecutar comando remoto en Windows vía WinRM (WS-Manager)
+ module: winremote_exec_winrm
+ args:
+ winuser: guillermo
+ winpassword: puertas
+ command: "cmd.exe /c echo hola > C:\\salida.txt"
+
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..3a9cdd9
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+# Module: install
+# Description: Instalador de ShFlow en modo local o global
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.3.0
+
+set -e
+
+# 🌐 Detectar idioma del sistema
+LANGUAGE="es"
+[[ "${LANG,,}" != *es* ]] && LANGUAGE="en"
+
+# 🗣️ Mensajes traducidos
+declare -A tr
+
+if [[ "$LANGUAGE" == "es" ]]; then
+ tr[logo]="🐙 SHFLOW"
+ tr[mode]="🔧 Instalando ShFlow en modo: %s"
+ tr[folder]="📁 Carpeta de instalación: %s"
+ tr[prev_detected]="⚠️ Instalación previa detectada en %s"
+ tr[preserve_vault]="📦 Preservando vault existente..."
+ tr[preserve_inventory]="📦 Preservando inventory existente..."
+ tr[preserve_modules]="📦 Preservando user_modules existente..."
+ tr[removing_old]="🧹 Eliminando instalación previa..."
+ tr[copying]="📦 Copiando archivos..."
+ tr[restore_vault]="🔁 Restaurando vault..."
+ tr[restore_inventory]="🔁 Restaurando inventory..."
+ tr[restore_modules]="🔁 Restaurando user_modules..."
+ tr[env_added]="✅ Variables añadidas a %s"
+ tr[env_exists]="ℹ️ SHFLOW_HOME ya está definido en %s"
+ tr[done]="🎉 Instalación completada correctamente."
+ tr[installed]="📦 Proyecto instalado en: %s"
+ tr[symlinks]="🔗 Symlinks creados en: %s"
+ tr[restart]="🧠 Recuerda reiniciar tu terminal o ejecutar: source %s"
+ tr[run]="👉 Puedes ejecutar 'shflow' desde cualquier ruta del terminal."
+else
+ tr[logo]="🐙 SHFLOW"
+ tr[mode]="🔧 Installing ShFlow in mode: %s"
+ tr[folder]="📁 Installation folder: %s"
+ tr[prev_detected]="⚠️ Previous installation detected at %s"
+ tr[preserve_vault]="📦 Preserving existing vault..."
+ tr[preserve_inventory]="📦 Preserving existing inventory..."
+ tr[preserve_modules]="📦 Preserving existing user_modules..."
+ tr[removing_old]="🧹 Removing previous installation..."
+ tr[copying]="📦 Copying files..."
+ tr[restore_vault]="🔁 Restoring vault..."
+ tr[restore_inventory]="🔁 Restoring inventory..."
+ tr[restore_modules]="🔁 Restoring user_modules..."
+ tr[env_added]="✅ Variables added to %s"
+ tr[env_exists]="ℹ️ SHFLOW_HOME already defined in %s"
+ tr[done]="🎉 Installation completed successfully."
+ tr[installed]="📦 Project installed at: %s"
+ tr[symlinks]="🔗 Symlinks created at: %s"
+ tr[restart]="🧠 Remember to restart your terminal or run: source %s"
+ tr[run]="👉 You can run 'shflow' from any terminal path."
+fi
+
+# 🖼️ Logo
+[[ -f "shflow-logo.ascii" ]] && cat shflow-logo.ascii || echo "${tr[logo]}"
+
+# 🧭 Detectar modo de instalación
+if [[ "$EUID" -eq 0 ]]; then
+ INSTALL_DIR="/opt/shflow"
+ BIN_DIR="/usr/local/bin"
+ MODE="global"
+else
+ INSTALL_DIR="$HOME/shflow"
+ BIN_DIR="$HOME/.local/bin"
+ MODE="local"
+fi
+
+printf "${tr[mode]}\n" "$MODE"
+printf "${tr[folder]}\n" "$INSTALL_DIR"
+
+mkdir -p "$INSTALL_DIR" "$BIN_DIR"
+
+# 🧹 Limpiar instalación previa
+if [[ -d "$INSTALL_DIR" ]]; then
+ printf "${tr[prev_detected]}\n" "$INSTALL_DIR"
+
+ [[ -d "$INSTALL_DIR/core/vault" ]] && echo "${tr[preserve_vault]}" && mv "$INSTALL_DIR/core/vault" /tmp/shflow_vault_backup
+ [[ -d "$INSTALL_DIR/core/inventory" ]] && echo "${tr[preserve_inventory]}" && mv "$INSTALL_DIR/core/inventory" /tmp/shflow_inventory_backup
+ [[ -d "$INSTALL_DIR/user_modules" ]] && echo "${tr[preserve_modules]}" && mv "$INSTALL_DIR/user_modules" /tmp/shflow_user_modules_backup
+
+ echo "${tr[removing_old]}"
+ rm -rf "$INSTALL_DIR"
+ mkdir -p "$INSTALL_DIR"
+fi
+
+# 📥 Copiar archivos
+echo "${tr[copying]}"
+for file in shflow.sh shflow.tr.es shflow.tr.en vault.sh vault.tr.es vault.tr.en LICENSE README.md; do cp "$file" "$INSTALL_DIR/"; done
+for dir in core community_modules user_modules examples; do cp -r "$dir" "$INSTALL_DIR/"; done
+
+# 🔁 Restaurar backups
+[[ -d "/tmp/shflow_vault_backup" ]] && echo "${tr[restore_vault]}" && rm -rf "$INSTALL_DIR/core/vault" && mv /tmp/shflow_vault_backup "$INSTALL_DIR/core/vault"
+[[ -d "/tmp/shflow_inventory_backup" ]] && echo "${tr[restore_inventory]}" && rm -rf "$INSTALL_DIR/core/inventory" && mv /tmp/shflow_inventory_backup "$INSTALL_DIR/core/inventory"
+[[ -d "/tmp/shflow_user_modules_backup" ]] && echo "${tr[restore_modules]}" && rm -rf "$INSTALL_DIR/user_modules" && mv /tmp/shflow_user_modules_backup "$INSTALL_DIR/user_modules"
+
+# 🔗 Symlinks
+ln -sf "$INSTALL_DIR/shflow.sh" "$BIN_DIR/shflow"
+ln -sf "$INSTALL_DIR/vault.sh" "$BIN_DIR/shflow-vault"
+ln -sf "$INSTALL_DIR/core/utils/shflow-doc.sh" "$BIN_DIR/shflow-doc"
+ln -sf "$INSTALL_DIR/core/utils/module-docgen.sh" "$BIN_DIR/module-docgen"
+ln -sf "$INSTALL_DIR/core/utils/shflow-check.sh" "$BIN_DIR/shflow-check"
+ln -sf "$INSTALL_DIR/core/utils/shflow-trust.sh" "$BIN_DIR/shflow-trust"
+ln -sf "$INSTALL_DIR/core/utils/shflow-ssh-init.sh" "$BIN_DIR/shflow-ssh-init"
+ln -sf "$INSTALL_DIR/core/utils/vault-init.sh" "$BIN_DIR/vault-init"
+
+# 🧠 Variables de entorno
+PROFILE_FILE="$HOME/.bashrc"
+[[ "$SHELL" == *zsh ]] && PROFILE_FILE="$HOME/.zshrc"
+
+if ! grep -q "SHFLOW_HOME" "$PROFILE_FILE"; then
+ echo "export SHFLOW_HOME=\"$INSTALL_DIR\"" >> "$PROFILE_FILE"
+ echo "export PATH=\"\$PATH:$BIN_DIR\"" >> "$PROFILE_FILE"
+ printf "${tr[env_added]}\n" "$PROFILE_FILE"
+else
+ printf "${tr[env_exists]}\n" "$PROFILE_FILE"
+fi
+
+# ✅ Finalización
+echo ""
+echo "${tr[done]}"
+printf "${tr[installed]}\n" "$INSTALL_DIR"
+printf "${tr[symlinks]}\n" "$BIN_DIR"
+printf "${tr[restart]}\n" "$PROFILE_FILE"
+echo "${tr[run]}"
diff --git a/shflow-logo.ascii b/shflow-logo.ascii
new file mode 100644
index 0000000..a5f1df8
--- /dev/null
+++ b/shflow-logo.ascii
@@ -0,0 +1,18 @@
+[?25l▊   
+▊ ┊▂▄▅▆▂▇▃▃▂▆▅▃▁  ▉ 
+▊ ▂▅▃▅▇┈ ▝▝┈ ┈▆▄▆▃┈  ▉ 
+▊ ▗▘▅▇┘ ▌▍ ┈▚▅▝▖ ▉ ▉ 
+▊ ▗▗▆▆▅▄╾▃┈ ▁▊▌▁ ┈▘▘ ▇▖▖   
+▊ ▗▗▏ ▇▖▅▂▄▅▅▄▆▄▃▇ ▊▉┊ ╺▂▂▂▂▂▂▂▂▂▂▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅▅ 
+▊ ▍▍ ┈▘▗▃╾▃▃▅▄▁▖▎ ▗▏▏ ╴▄▆▁▁▆▄┊▊▇▌ ▅▅▅▅▅▅▏▅▅▏ 
+▊▉▏▁▃╼━━┉▖▉▏ ┈▌▋┊╴▘▘▝▃▃▃▅┊▉▏ ▊ ▝▁▁▝▅▏▊┊▌▅▆▅▖ ▎▉▇▇▇▇ ▎▉▏┈▃▄▅▄▂ ▗▄▏ ▄▄▖ ▗▄╴ 
+▊ ▏▎┈ ▍▊▁▚▅▇▖▆▆▇▂▃▃▂┈╶┈▘ ▅▄▃▂▇▅▏▊▗▗┈▇▏▉▏▎ ▇▇▇▌ ▎▉▏▍▉▘▇▖┊▖▝┊▌▗┊▖▋▗┊▌ 
+▊ ▍▌ ┈▘┊▚┈ ▗▘┈▄▇▗▅▅▖▇▅▂▅▁ ▋▆▃▂▁▘ ▍▊▌▌ ▉▏▉▏▎▉▏ ▎▉▏▎▉▖┈▋ ▌ ▍▉▘▗▝┊▘▉▏ 
+▊ ▝▉▖ ▗▗▇▖▝▖┈▎▊▌ ▉▏▝▘▊ ╴▅▄━╸ ▆▄▃▃▄▆ ▝▄▘ ▄▄▏▃▃▏ ▃▃▏┈▄▃▂▃▅┈ ▉▃▃▘ ▃▃▘ 
+▊ ▝▝▖▗▗ ┈▝▂▅▏▉▏ ▇▆▆┈ ┈┈╺┒▁ ▗▃▏ ▃▃▃▃▃▃▃▃▃▃▃▃ ▗▃ 
+▊ ▝▖▘┊ ┈▆▖ ▖ ▗▁▅━▃▇╺▖▍  
+▊ ┈▄▝▃┈ ▝┈╴▄┈╶▃▝╾▃▆┕╸▁┗▖┗▌  
+▏ ┈▅▂▅▃▁ ┈▖ ╵▇▍▇▅╾▃▆▚▁┗▏▘ ▍ ▇▇  
+▏ ▇▅▄▂▆▅┊┈▗┊ ▇┓┊▌┈┈ ▝ ▗╺▄▄▄▄▄▄▄▄▄ 
+▏▄▄▄▄▄▄▄▄▄▅▄▄▄▅▇▇▇ ▅▄▄▄▄▇▄▄▄▄▄▄▅▏▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ ▆▆▆▆▆▆▆▆▆▆
+[?25h \ No newline at end of file
diff --git a/shflow-logo.png b/shflow-logo.png
new file mode 100644
index 0000000..8924473
--- /dev/null
+++ b/shflow-logo.png
Binary files differ
diff --git a/shflow.sh b/shflow.sh
new file mode 100755
index 0000000..f823f78
--- /dev/null
+++ b/shflow.sh
@@ -0,0 +1,308 @@
+#!/bin/bash
+# ShFlow Playbook Runner
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.8.0
+
+set -euo pipefail
+
+# 📁 Rutas clave
+PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
+INVENTORY="$PROJECT_ROOT/core/inventory/hosts.yaml"
+VAULT_DIR="$PROJECT_ROOT/core/vault"
+VAULT_KEY="${VAULT_KEY:-$HOME/.shflow.key}"
+
+# 🌐 Cargar render_msg y traducciones
+COMMON_LIB="$PROJECT_ROOT/core/lib/translate_msg.sh"
+if ! declare -f render_msg &>/dev/null; then
+ [[ -f "$COMMON_LIB" ]] && source "$COMMON_LIB"
+fi
+
+lang="${SHFLOW_LANG:-es}"
+trfile="$PROJECT_ROOT/shflow.tr.${lang}"
+declare -A tr
+if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+# 🌀 Banner institucional
+shflow_banner() {
+ local banner=$(grep -E '^# Version:' "$0" | sed 's/^# Version:/ShFlow version:/')
+ local padding=" "
+ echo "🌀 $banner$padding"
+}
+shflow_banner
+
+# 🔧 Verbosidad y variables
+SHFLOW_VERBOSITY=1
+PLAYBOOK=""
+HOST=""
+GROUP=""
+DEBUG=false
+declare -A shflow_vars
+
+# 📣 Trazas condicionales
+echolog() {
+ local level="$1"; shift
+ local message="$*"
+ local verbosity="${TASK_VERBOSITY:-$SHFLOW_VERBOSITY}"
+ [[ "$verbosity" -ge "$level" ]] && echo "$message"
+}
+
+# 🔐 Resolución de secretos
+resolve_vault_references() {
+ local input="$1"
+ local output="$input"
+ local pattern='\{\{\s*vault\((["'\''])([^"'\''\)]+)\1\)\s*\}\}'
+ while [[ "$output" =~ $pattern ]]; do
+ local full="${BASH_REMATCH[0]}"
+ local key="${BASH_REMATCH[2]}"
+ local secret=""
+ if [[ -f "$VAULT_DIR/$key.gpg" ]]; then
+ secret=$(gpg --quiet --batch --yes --passphrase-file "$VAULT_KEY" -d "$VAULT_DIR/$key.gpg" 2>/dev/null || true)
+ fi
+ output="${output//$full/$secret}"
+ done
+ echo "$output"
+}
+
+# 🧠 Interpolación de argumentos
+interpolate_args() {
+ local raw="$1" host="$2" label="$3"
+ local result="$raw"
+ result="$(resolve_vault_references "$result")"
+ result="${result//\{\{ name \}\}/$host}"
+ result="${result//\{\{ label \}\}/$label}"
+ for var in "${!shflow_vars[@]}"; do
+ safe_value="${shflow_vars[$var]}"
+ safe_value="${safe_value//$'\n'/\\n}"
+ safe_value="${safe_value//$'\r'/\\r}"
+ safe_value="${safe_value//$'\t'/\\t}"
+ safe_value="${safe_value//$'\0'/ }"
+ result="${result//\{\{ $var \}\}/$safe_value}"
+ done
+ echo "$result"
+}
+
+# 🧪 Validación de argumentos
+if [[ $# -eq 0 ]]; then
+ echo "${tr[no_args]:-❌ No se especificaron argumentos. Usa -f <archivo.yaml> y -h <host> o -g <grupo>}"
+ exit 1
+fi
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -f|--file)
+ [[ -z "${2:-}" || "${2:-}" == -* ]] && echo "${tr[missing_file]:-❌ Falta el nombre del archivo YAML tras $1}" && exit 1
+ PLAYBOOK="$2"; shift 2 ;;
+ -h|--host)
+ [[ -z "${2:-}" || "${2:-}" == -* ]] && echo "${tr[missing_host]:-❌ Falta el nombre del host tras $1}" && exit 1
+ HOST="$2"; shift 2 ;;
+ -g|--group)
+ [[ -z "${2:-}" || "${2:-}" == -* ]] && echo "${tr[missing_group]:-❌ Falta el nombre del grupo tras $1}" && exit 1
+ GROUP="$2"; shift 2 ;;
+ --quiet) SHFLOW_VERBOSITY=0; shift ;;
+ --verbose) SHFLOW_VERBOSITY=2; shift ;;
+ --debug) SHFLOW_VERBOSITY=3; DEBUG=true; shift ;;
+ --version|version)
+ echo "$(render_msg "${tr[version_path]:-Ubicación: {path}" "path=$(realpath "$0")")"
+ exit 0 ;;
+ --help)
+ echo -e "${tr[help_header]:-ShFlow — Automatización ligera y extensible con Shell}\n"
+ echo "${tr[help_usage]:-Uso: shflow -f <archivo.yaml> [-h <host> | -g <grupo>] [opciones]}"
+ echo ""
+ echo "${tr[help_options]:-Opciones:}"
+ echo "${tr[help_opt_file]:- -f, --file Playbook YAML a ejecutar}"
+ echo "${tr[help_opt_host]:- -h, --host Host individual del inventario}"
+ echo "${tr[help_opt_group]:- -g, --group Grupo de hosts del inventario}"
+ echo "${tr[help_opt_quiet]:- --quiet Silencia toda salida excepto errores}"
+ echo "${tr[help_opt_verbose]:- --verbose Muestra trazas detalladas}"
+ echo "${tr[help_opt_debug]:- --debug Modo depuración con trazas internas}"
+ echo "${tr[help_opt_version]:- --version Muestra ubicación del ejecutable}"
+ echo "${tr[help_opt_help]:- --help Muestra esta ayuda}"
+ echo ""
+ echo "${tr[help_example]:-Ejemplo:}"
+ echo "${tr[help_example_cmd]:- shflow -f tareas.yaml -g servidores --verbose}"
+ exit 0 ;;
+ *)
+ $PROJECT_ROOT/core/utils/eg.sh "$@"
+ echo "$(render_msg "${tr[unknown_option]:-❌ Opción desconocida: {opt}}" "opt=$1")"
+ exit 1 ;;
+ esac
+done
+
+# 📋 Validación de playbook
+[ -z "$PLAYBOOK" ] && echo "${tr[no_playbook]:-❌ Playbook no especificado. Usa -f <archivo.yaml>}" && exit 1
+[ ! -f "$PLAYBOOK" ] && echo "$(render_msg "${tr[playbook_not_found]:-❌ Playbook no encontrado: {file}}" "file=$PLAYBOOK")" && exit 1
+
+TASKS_JSON=$(yq -r .tasks "$PLAYBOOK")
+NUM_TASKS=$(echo "$TASKS_JSON" | jq 'length')
+[ "$NUM_TASKS" -eq 0 ] && echo "${tr[no_tasks]:-❌ No se encontraron tareas en el playbook.}" && exit 1
+
+# 🧠 Resolución de hosts
+HOSTS=()
+if [ -n "$HOST" ]; then
+ HOSTS+=("$HOST")
+elif [ -n "$GROUP" ]; then
+ HOSTS_RAW=$(yq ".all.children.\"$GROUP\".hosts | keys | .[]" "$INVENTORY")
+ [ -z "$HOSTS_RAW" ] && echo "$(render_msg "${tr[group_not_found]:-❌ Grupo '{group}' no encontrado en el inventario.}" "group=$GROUP")" && exit 1
+ while IFS= read -r line; do HOSTS+=("$(echo "$line" | sed 's/^\"\(.*\)\"$/\1/')"); done <<< "$HOSTS_RAW"
+else
+ HOSTS_LINE=$(yq -r '.hosts // ""' "$PLAYBOOK")
+ if [ -z "$HOSTS_LINE" ]; then
+ HOSTGROUP=$(yq -r '.hostgroup // ""' "$PLAYBOOK")
+ if [ -n "$HOSTGROUP" ]; then
+ HOSTS_RAW=$(yq ".all.children.\"$HOSTGROUP\".hosts | keys | .[]" "$INVENTORY")
+ [ -z "$HOSTS_RAW" ] && echo "$(render_msg "${tr[group_not_found]:-❌ Grupo '{group}' no encontrado en el inventario.}" "group=$HOSTGROUP")" && exit 1
+ while IFS= read -r line; do HOSTS+=("$(echo "$line" | sed 's/^\"\(.*\)\"$/\1/')"); done <<< "$HOSTS_RAW"
+ else
+ echo "${tr[no_host_specified]:-❌ No se especificó ningún host. Usa -h, -g, 'hosts:' o 'hostgroup:' en el playbook.}"
+ exit 1
+ fi
+ else
+ IFS=',' read -ra HOSTS <<< "$HOSTS_LINE"
+ for i in "${!HOSTS[@]}"; do HOSTS[$i]=$(echo "${HOSTS[$i]}" | xargs); done
+ fi
+fi
+
+# 📦 Carga de variables globales
+GLOBAL_VARS="$PROJECT_ROOT/core/inventory/vars/all.yaml"
+if [[ -f "$GLOBAL_VARS" ]]; then
+ GLOBAL_KEYS=$(yq -r 'keys[]' "$GLOBAL_VARS")
+ for key in $GLOBAL_KEYS; do
+ raw_value=$(yq -r ".\"$key\"" "$GLOBAL_VARS")
+ resolved_value="$(resolve_vault_references "$raw_value")"
+ shflow_vars["$key"]="$resolved_value"
+ done
+fi
+
+# 📦 Carga de variables locales del playbook
+VARS_KEYS=$(yq -r '.vars | keys[]' "$PLAYBOOK" 2>/dev/null || true)
+for key in $VARS_KEYS; do
+ raw_value=$(yq -r ".vars.\"$key\"" "$PLAYBOOK")
+ resolved_value="$(resolve_vault_references "$raw_value")"
+ shflow_vars["$key"]="$resolved_value"
+done
+
+# 👤 Usuario remoto
+REMOTE_USER="${shflow_vars["remote_user"]:-$USER}"
+
+# 🚀 Ejecución por host
+run_for_host() {
+ local CURRENT_HOST="$1"
+ local HOST_IP LABEL
+ local output_buffer=$(mktemp)
+
+ {
+ HOST_IP=$(yq ".all.hosts.\"$CURRENT_HOST\".ansible_host" "$INVENTORY" | sed 's/^\"\(.*\)\"$/\1/')
+ LABEL=$(yq ".all.hosts.\"$CURRENT_HOST\".label" "$INVENTORY" | sed 's/^\"\(.*\)\"$/\1/')
+ [[ "$HOST_IP" == "null" || -z "$HOST_IP" ]] && HOST_IP="$CURRENT_HOST"
+ [[ "$LABEL" == "null" || -z "$LABEL" ]] && LABEL="$CURRENT_HOST"
+
+ echolog 1 "$(render_msg "${tr[host_info]:-🔧 Host: {host} ({ip})}" "host=$CURRENT_HOST" "ip=$HOST_IP")"
+ echolog 2 "$(render_msg "${tr[ssh_user]:-👤 Usuario SSH: {user}}" "user=$REMOTE_USER")"
+
+ # (Las tareas se ejecutarán en Parte 4)
+ } > "$output_buffer" 2>&1
+
+ echo -e "\n🖥️ Host: $CURRENT_HOST\n$(cat "$output_buffer")"
+ rm -f "$output_buffer"
+}
+
+ for ((i=0; i<NUM_TASKS; i++)); do
+ VERBOSITY_RAW=$(echo "$TASKS_JSON" | jq -r ".[$i].verbosity // empty")
+ TASK_VERBOSITY="$SHFLOW_VERBOSITY"
+
+ case "${VERBOSITY_RAW,,}" in
+ quiet) TASK_VERBOSITY=0 ;;
+ normal|default) TASK_VERBOSITY=1 ;;
+ verbose) TASK_VERBOSITY=2 ;;
+ debug) TASK_VERBOSITY=3 ;;
+ esac
+
+ NAME=$(echo "$TASKS_JSON" | jq -r ".[$i].name")
+ MODULE=$(echo "$TASKS_JSON" | jq -r ".[$i].module")
+ ARGS_RAW=$(echo "$TASKS_JSON" | jq -c ".[$i].args")
+ COND_RAW=$(echo "$TASKS_JSON" | jq -r ".[$i].condition // \"\"")
+ CAPTURE_LOG=$(echo "$TASKS_JSON" | jq -r ".[$i].capture_log // \"\"")
+ CAPTURE_ERR=$(echo "$TASKS_JSON" | jq -r ".[$i].capture_err // \"\"")
+ REGISTER=$(echo "$TASKS_JSON" | jq -r ".[$i].register // \"\"")
+
+ if [ -n "$COND_RAW" ]; then
+ COND_EVAL="$(resolve_vault_references "$COND_RAW")"
+ for key in "${!shflow_vars[@]}"; do
+ COND_EVAL="${COND_EVAL//\{\{ $key \}\}/${shflow_vars[$key]}}"
+ done
+ COND_EVAL="${COND_EVAL//\{\{ name \}\}/$CURRENT_HOST}"
+ COND_EVAL="${COND_EVAL//\{\{ label \}\}/$LABEL}"
+
+ if ! bash -c "$COND_EVAL"; then
+ echolog 2 "$(render_msg "${tr[task_skipped]:-⏭️ Tarea OMITIDA \"{name}\" por condición: {condition}}" "name=$NAME" "condition=$COND_EVAL")"
+ continue
+ else
+ echolog 2 "$(render_msg "${tr[condition_met]:-🔍 Condición cumplida: {condition}}" "condition=$COND_EVAL")"
+ fi
+ fi
+
+ echolog 1 "$(render_msg "${tr[task_running]:-🔧 Ejecutando tarea: \"{name}\" (módulo: \"{module}\")}" "name=$NAME" "module=$MODULE")"
+
+ MODULE_PATH=""
+ SEARCH_PATHS=("$PROJECT_ROOT/core/modules" "$PROJECT_ROOT/user_modules" "$PROJECT_ROOT/community_modules")
+ for search_dir in "${SEARCH_PATHS[@]}"; do
+ while IFS= read -r -d '' candidate; do
+ [[ "$(basename "$candidate")" == "${MODULE}.sh" ]] && MODULE_PATH="$candidate" && break 2
+ done < <(find "$search_dir" -type f -name "${MODULE}.sh" -print0)
+ done
+
+ [ -z "$MODULE_PATH" ] && echo "$(render_msg "${tr[module_not_found]:-❌ Módulo no encontrado: {module}.sh en rutas conocidas}" "module=$MODULE")" && continue
+ source "$MODULE_PATH"
+ ! declare -f "${MODULE}_task" > /dev/null && echo "$(render_msg "${tr[function_not_found]:-❌ Función '{function}' no encontrada en el módulo}" "function=${MODULE}_task")" && continue
+
+ INTERPOLATED_ARGS="$(interpolate_args "$ARGS_RAW" "$CURRENT_HOST" "$LABEL")"
+ ARG_KEYS=$(echo "$INTERPOLATED_ARGS" | jq -r 'keys[]')
+ ARG_VALUES=()
+ for key in $ARG_KEYS; do
+ resolved=$(echo "$INTERPOLATED_ARGS" | jq -r ".[\"$key\"]")
+ resolved=$(echo "$resolved" | sed 's/^\"\(.*\)\"$/\1/')
+ ARG_VALUES+=("${key}=${resolved}")
+ done
+
+ for extra_key in become; do
+ if [[ ! " ${ARG_KEYS[*]} " =~ " ${extra_key} " ]]; then
+ value="${shflow_vars[$extra_key]:-}"
+ [[ -n "$value" ]] && ARG_VALUES+=("${extra_key}=${value}")
+ fi
+ done
+
+ local output exit_code
+ set +e
+ output=$("${MODULE}_task" "$REMOTE_USER@$HOST_IP" "${ARG_VALUES[@]}" 2>&1)
+ exit_code=$?
+ set -e
+
+ [[ -n "$CAPTURE_LOG" ]] && shflow_vars["$CAPTURE_LOG"]="$output"
+ [[ -n "$REGISTER" ]] && shflow_vars["$REGISTER"]="$output"
+ [[ -n "$CAPTURE_ERR" ]] && shflow_vars["$CAPTURE_ERR"]="$exit_code"
+
+ [[ -n "$CAPTURE_LOG" ]] && export "shflow_vars_${CAPTURE_LOG}=${shflow_vars[$CAPTURE_LOG]}"
+ [[ -n "$REGISTER" ]] && export "shflow_vars_${REGISTER}=${shflow_vars[$REGISTER]}"
+ [[ -n "$CAPTURE_ERR" ]] && export "shflow_vars_${CAPTURE_ERR}=${shflow_vars[$CAPTURE_ERR]}"
+
+ echo "$output"
+ [ "$exit_code" -ne 0 ] && echo "$(render_msg "${tr[task_failed]:-⚠️ Tarea '{name}' falló en host '{host}'}" "name=$NAME" "host=$CURRENT_HOST")"
+ echo ""
+ done
+ } > "$output_buffer" 2>&1
+
+ echo -e "\n🖥️ Host: $CURRENT_HOST\n$(cat "$output_buffer")"
+ rm -f "$output_buffer"
+}
+
+# ⚙️ Ejecución paralela o secuencial
+if [[ "$PARALLELISM" == "true" ]]; then
+ for H in "${HOSTS[@]}"; do run_for_host "$H" & done
+ wait
+else
+ for H in "${HOSTS[@]}"; do run_for_host "$H"; done
+fi
+
+# 🧹 Cierre defensivo
+return 0 2>/dev/null || true
diff --git a/shflow.tr.en b/shflow.tr.en
new file mode 100644
index 0000000..ff2c4f1
--- /dev/null
+++ b/shflow.tr.en
@@ -0,0 +1,32 @@
+no_args=❌ No arguments specified. Use -f <file.yaml> and -h <host> or -g <group>
+missing_file=❌ Missing YAML file name after {flag}
+missing_host=❌ Missing host name after {flag}
+missing_group=❌ Missing group name after {flag}
+version_path=Location: {path}
+help_header=ShFlow — Lightweight and extensible automation with Shell
+help_usage=Usage: shflow -f <file.yaml> [-h <host> | -g <group>] [options]
+help_options=Options:
+help_opt_file= -f, --file YAML playbook to execute
+help_opt_host= -h, --host Individual host from inventory
+help_opt_group= -g, --group Group of hosts from inventory
+help_opt_quiet= --quiet Silence all output except errors
+help_opt_verbose= --verbose Show detailed traces
+help_opt_debug= --debug Debug mode with internal traces
+help_opt_version= --version Show executable location
+help_opt_help= --help Show this help
+help_example=Example:
+help_example_cmd= shflow -f tasks.yaml -g servers --verbose
+unknown_option=❌ Unknown option: {opt}
+no_playbook=❌ No playbook specified. Use -f <file.yaml>
+playbook_not_found=❌ Playbook not found: {file}
+no_tasks=❌ No tasks found in the playbook.
+group_not_found=❌ Group '{group}' not found in inventory.
+no_host_specified=❌ No host specified. Use -h, -g, 'hosts:' or 'hostgroup:' in the playbook.
+module_not_found=❌ Module not found: {module}.sh in known paths
+function_not_found=❌ Function '{function}' not found in module
+task_skipped=⏭️ Task SKIPPED "{name}" due to condition: {condition}
+task_running=🔧 Running task: "{name}" (module: "{module}")
+task_failed=⚠️ Task '{name}' failed on host '{host}'
+ssh_user=👤 SSH user: {user}
+host_info=🔧 Host: {host} ({ip})
+condition_met=🔍 Condition met: {condition}
diff --git a/shflow.tr.es b/shflow.tr.es
new file mode 100644
index 0000000..2f6f642
--- /dev/null
+++ b/shflow.tr.es
@@ -0,0 +1,32 @@
+no_args=❌ No se especificaron argumentos. Usa -f <archivo.yaml> y -h <host> o -g <grupo>
+missing_file=❌ Falta el nombre del archivo YAML tras {flag}
+missing_host=❌ Falta el nombre del host tras {flag}
+missing_group=❌ Falta el nombre del grupo tras {flag}
+version_path=Ubicación: {path}
+help_header=ShFlow — Automatización ligera y extensible con Shell
+help_usage=Uso: shflow -f <archivo.yaml> [-h <host> | -g <grupo>] [opciones]
+help_options=Opciones:
+help_opt_file= -f, --file Playbook YAML a ejecutar
+help_opt_host= -h, --host Host individual del inventario
+help_opt_group= -g, --group Grupo de hosts del inventario
+help_opt_quiet= --quiet Silencia toda salida excepto errores
+help_opt_verbose= --verbose Muestra trazas detalladas
+help_opt_debug= --debug Modo depuración con trazas internas
+help_opt_version= --version Muestra ubicación del ejecutable
+help_opt_help= --help Muestra esta ayuda
+help_example=Ejemplo:
+help_example_cmd= shflow -f tareas.yaml -g servidores --verbose
+unknown_option=❌ Opción desconocida: {opt}
+no_playbook=❌ Playbook no especificado. Usa -f <archivo.yaml>
+playbook_not_found=❌ Playbook no encontrado: {file}
+no_tasks=❌ No se encontraron tareas en el playbook.
+group_not_found=❌ Grupo '{group}' no encontrado en el inventario.
+no_host_specified=❌ No se especificó ningún host. Usa -h, -g, 'hosts:' o 'hostgroup:' en el playbook.
+module_not_found=❌ Módulo no encontrado: {module}.sh en rutas conocidas
+function_not_found=❌ Función '{function}' no encontrada en el módulo
+task_skipped=⏭️ Tarea OMITIDA "{name}" por condición: {condition}
+task_running=🔧 Ejecutando tarea: "{name}" (módulo: "{module}")
+task_failed=⚠️ Tarea '{name}' falló en host '{host}'
+ssh_user=👤 Usuario SSH: {user}
+host_info=🔧 Host: {host} ({ip})
+condition_met=🔍 Condición cumplida: {condition}
diff --git a/user_modules/.gitignore b/user_modules/.gitignore
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/user_modules/.gitignore
diff --git a/vault.sh b/vault.sh
new file mode 100755
index 0000000..cddeb92
--- /dev/null
+++ b/vault.sh
@@ -0,0 +1,146 @@
+#!/usr/bin/env bash
+# ShFlow Vault Manager
+# License: GPLv3
+# Author: Luis GuLo
+# Version: 1.5.1
+# Dependencies: gpg
+
+set -euo pipefail
+
+# 🧭 Detección de la raíz del proyecto
+PROJECT_ROOT="${SHFLOW_HOME:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
+
+# 📁 Rutas clave
+VAULT_DIR="$PROJECT_ROOT/core/vault"
+VAULT_KEY="${VAULT_KEY:-$HOME/.shflow.key}"
+VAULT_PUBKEY="${VAULT_PUBKEY:-$HOME/.shflow.pub}"
+VAULT_RECIPIENT="${VAULT_RECIPIENT:-}"
+
+# 🧩 Cargar render_msg si no está disponible
+COMMON_LIB="$PROJECT_ROOT/core/lib/translate_msg.sh"
+if ! declare -f render_msg &>/dev/null; then
+ [[ -f "$COMMON_LIB" ]] && source "$COMMON_LIB"
+fi
+
+# 🌐 Cargar traducciones
+lang="${SHFLOW_LANG:-es}"
+trfile="$PROJECT_ROOT/vault.tr.${lang}"
+declare -A tr
+if [[ -f "$trfile" ]]; then while IFS='=' read -r k v; do tr["$k"]="$v"; done < "$trfile"; fi
+
+encrypt_secret() {
+ local key="$1"
+ local value="$2"
+
+ if [ -f "$VAULT_PUBKEY" ]; then
+ echo "$(render_msg "${tr[encrypt_asym]:-🔐 Usando cifrado asimétrico para '{key}'}" "key=$key")"
+ echo "$value" | gpg --encrypt --armor --batch --yes --recipient "$VAULT_RECIPIENT" -o "$VAULT_DIR/$key.gpg"
+ elif [ -f "$VAULT_KEY" ]; then
+ echo "$(render_msg "${tr[encrypt_sym]:-🔐 Usando cifrado simétrico para '{key}'}" "key=$key")"
+ echo "$value" | gpg --symmetric --batch --yes --passphrase-file "$VAULT_KEY" -o "$VAULT_DIR/$key.gpg"
+ else
+ echo "${tr[missing_key]:-❌ No se encontró clave para cifrar. Ejecuta vault-init.sh primero.}"
+ return 1
+ fi
+
+ echo "$(render_msg "${tr[secret_saved]:-✅ Secreto '{key}' guardado en {dir}}" "key=$key" "dir=$VAULT_DIR")"
+}
+
+decrypt_secret() {
+ local key="$1"
+ gpg --quiet --batch --yes --passphrase-file "$VAULT_KEY" -d "$VAULT_DIR/$key.gpg" 2>/dev/null || \
+ gpg --quiet --batch --yes -d "$VAULT_DIR/$key.gpg"
+}
+
+list_secrets() {
+ ls "$VAULT_DIR"/*.gpg 2>/dev/null | sed 's|.*/\(.*\)\.gpg|\1|'
+}
+
+secret_exists() {
+ local key="$1"
+ [[ -f "$VAULT_DIR/$key.gpg" ]]
+}
+
+remove_secret() {
+ local key="$1"
+ rm -f "$VAULT_DIR/$key.gpg" && echo "$(render_msg "${tr[secret_removed]:-🗑️ Secreto '{key}' eliminado.}" "key=$key")"
+}
+
+edit_secret() {
+ local key="$1"
+ local current
+ current=$(decrypt_secret "$key")
+ read -s -p "$(render_msg "${tr[edit_prompt]:-🔑 Nuevo valor para '{key}':}" "key=$key")" new_value
+ echo ""
+ encrypt_secret "$key" "$new_value"
+}
+
+export_secrets() {
+ for file in "$VAULT_DIR"/*.gpg; do
+ varname="$(basename "$file" .gpg)"
+ value="$(decrypt_secret "$varname")"
+ echo "export $varname=\"$value\""
+ done
+}
+
+vault_task() {
+ local host="$1"; shift
+ declare -A args
+ for arg in "$@"; do
+ key="${arg%%=*}"
+ value="${arg#*=}"
+ args["$key"]="$value"
+ done
+
+ local action="${args[action]:-}"
+ local key="${args[key]:-}"
+ local value="${args[value]:-}"
+ local become="${args[become]:-}"
+ local prefix=""
+ [ "$become" = "true" ] && prefix="sudo"
+
+ case "$action" in
+ get|show) decrypt_secret "$key" ;;
+ add) encrypt_secret "$key" "$value" ;;
+ edit) edit_secret "$key" ;;
+ remove) remove_secret "$key" ;;
+ exists) secret_exists "$key" ;;
+ list) list_secrets ;;
+ export) export_secrets ;;
+ *) echo "$(render_msg "${tr[action_invalid]:-❌ [vault] Acción '{action}' no soportada.}" "action=$action")"; return 1 ;;
+ esac
+}
+
+check_dependencies_vault() {
+ if ! command -v gpg &> /dev/null; then
+ echo "${tr[missing_dep]:-❌ [vault] gpg no está disponible.}"
+ return 1
+ fi
+ echo "${tr[dep_ok]:-✅ [vault] gpg disponible.}"
+ return 0
+}
+
+main() {
+ case "${1:-}" in
+ add)
+ read -s -p "$(render_msg "${tr[cli_prompt]:-🔑 Valor para '{key}':}" "key=$2")" value
+ echo ""
+ encrypt_secret "$2" "$value"
+ ;;
+ get|show) decrypt_secret "$2" ;;
+ edit) edit_secret "$2" ;;
+ remove) remove_secret "$2" ;;
+ list) list_secrets ;;
+ export) export_secrets ;;
+ exists)
+ secret_exists "$2" && echo "${tr[exists]:-✅ Existe}" || echo "${tr[not_exists]:-❌ No existe}"
+ ;;
+ *)
+ echo "${tr[usage]:-Uso: vault.sh {add|get|show|edit|remove|list|export|exists} <clave>}"
+ ;;
+ esac
+}
+
+if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
+ main "$@"
+fi
diff --git a/vault.tr.en b/vault.tr.en
new file mode 100644
index 0000000..6588c64
--- /dev/null
+++ b/vault.tr.en
@@ -0,0 +1,13 @@
+encrypt_asym=🔐 Using asymmetric encryption for '{key}'
+encrypt_sym=🔐 Using symmetric encryption for '{key}'
+missing_key=❌ No key found for encryption. Run vault-init.sh first.
+secret_saved=✅ Secret '{key}' saved in {dir}
+secret_removed=🗑️ Secret '{key}' removed.
+edit_prompt=🔑 New value for '{key}':
+cli_prompt=🔑 Value for '{key}':
+exists=✅ Exists
+not_exists=❌ Does not exist
+usage=Usage: vault.sh {add|get|show|edit|remove|list|export|exists} <key>
+action_invalid=❌ [vault] Action '{action}' not supported.
+missing_dep=❌ [vault] gpg is not available.
+dep_ok=✅ [vault] gpg is available.
diff --git a/vault.tr.es b/vault.tr.es
new file mode 100644
index 0000000..2db6f47
--- /dev/null
+++ b/vault.tr.es
@@ -0,0 +1,13 @@
+encrypt_asym=🔐 Usando cifrado asimétrico para '{key}'
+encrypt_sym=🔐 Usando cifrado simétrico para '{key}'
+missing_key=❌ No se encontró clave para cifrar. Ejecuta vault-init.sh primero.
+secret_saved=✅ Secreto '{key}' guardado en {dir}
+secret_removed=🗑️ Secreto '{key}' eliminado.
+edit_prompt=🔑 Nuevo valor para '{key}':
+cli_prompt=🔑 Valor para '{key}':
+exists=✅ Existe
+not_exists=❌ No existe
+usage=Uso: vault.sh {add|get|show|edit|remove|list|export|exists} <clave>
+action_invalid=❌ [vault] Acción '{action}' no soportada.
+missing_dep=❌ [vault] gpg no está disponible.
+dep_ok=✅ [vault] gpg disponible.