@fazer-ai/agents 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/agents/claude.js +152 -0
- package/dist/agents/codex.js +155 -0
- package/dist/agents/detect.js +15 -0
- package/dist/agents/handoff.js +22 -0
- package/dist/agents/hermes-skills.js +177 -0
- package/dist/agents/hermes.js +474 -0
- package/dist/agents/index.js +57 -0
- package/dist/agents/manual.js +22 -0
- package/dist/agents/other.js +39 -0
- package/dist/agents/shell.js +15 -0
- package/dist/agents/types.js +2 -0
- package/dist/config.js +48 -0
- package/dist/exec.js +279 -0
- package/dist/hostinger.js +75 -0
- package/dist/hub-command.js +144 -0
- package/dist/index.js +726 -0
- package/dist/licenses.js +93 -0
- package/dist/mcp.js +100 -0
- package/dist/oauth.js +578 -0
- package/dist/onboarding-marker.js +48 -0
- package/dist/preferences.js +40 -0
- package/dist/skills/agents-dev/SKILL.md +37 -0
- package/dist/skills/agents-dev/gotchas.md +6 -0
- package/dist/skills/agents-dev/guardrails.md +6 -0
- package/dist/skills/agents-dev/references/00-get-the-code.md +28 -0
- package/dist/skills/agents-dev/references/01-layout-and-bun-check.md +29 -0
- package/dist/skills/agents-dev/references/02-free-full-and-invariants.md +7 -0
- package/dist/skills/agents-dev/references/03-implement.md +9 -0
- package/dist/skills/agents-dev/references/04-own-image-and-deploy.md +13 -0
- package/dist/skills/agents-onboarding/SKILL.md +80 -0
- package/dist/skills/agents-onboarding/gotchas.md +157 -0
- package/dist/skills/agents-onboarding/guardrails.md +65 -0
- package/dist/skills/agents-onboarding/references/00-prereqs-and-access.md +37 -0
- package/dist/skills/agents-onboarding/references/01-vps-dns-ssh.md +67 -0
- package/dist/skills/agents-onboarding/references/01b-brownfield.md +70 -0
- package/dist/skills/agents-onboarding/references/01c-pick-tier.md +61 -0
- package/dist/skills/agents-onboarding/references/02-coolify.md +109 -0
- package/dist/skills/agents-onboarding/references/03-chatwoot-pro.md +61 -0
- package/dist/skills/agents-onboarding/references/04-agents-image.md +46 -0
- package/dist/skills/agents-onboarding/references/05-langfuse.md +45 -0
- package/dist/skills/agents-onboarding/references/06-setup-and-mcp.md +47 -0
- package/dist/skills/agents-onboarding/references/08-agent-import.md +55 -0
- package/dist/skills/agents-onboarding/references/09-chatwoot-bind.md +41 -0
- package/dist/skills/agents-onboarding/references/10-validate-e2e.md +34 -0
- package/dist/skills/agents-onboarding/references/agent-features.md +61 -0
- package/dist/skills/agents-onboarding/references/chatwoot-hub-register.md +69 -0
- package/dist/skills/agents-onboarding/references/deploy-b-portainer.md +138 -0
- package/dist/skills/agents-onboarding/references/deploy-c-compose.md +64 -0
- package/dist/skills/agents-onboarding/samples/agents/README.md +23 -0
- package/dist/skills/agents-onboarding/samples/agents/maria-clinica-moreira.json +313 -0
- package/dist/skills/agents-onboarding/scripts/chatwoot-admin.py +248 -0
- package/dist/skills/agents-onboarding/scripts/coolify.py +552 -0
- package/dist/skills/agents-onboarding/scripts/docker-status.py +129 -0
- package/dist/skills/agents-onboarding/scripts/gen-onboarding-env.ts +187 -0
- package/dist/skills/agents-onboarding/scripts/harbor-login.py +118 -0
- package/dist/skills/agents-onboarding/scripts/langfuse-verify.py +118 -0
- package/dist/skills/agents-onboarding/scripts/portainer-brownfield.py +115 -0
- package/dist/skills/agents-onboarding/scripts/remote.py +198 -0
- package/dist/skills/agents-onboarding/scripts/sshkey.py +140 -0
- package/dist/skills/agents-onboarding/templates/chatwoot/.env.example +30 -0
- package/dist/skills/agents-onboarding/templates/chatwoot/README.md +65 -0
- package/dist/skills/agents-onboarding/templates/chatwoot/docker-compose.coolify.yml +136 -0
- package/dist/skills/agents-onboarding/templates/chatwoot/docker-compose.yml +139 -0
- package/dist/skills/agents-onboarding/templates/docker-compose.coolify.yml +73 -0
- package/dist/skills/agents-onboarding/templates/docker-compose.portainer.yml +132 -0
- package/dist/skills/agents-onboarding/templates/docker-compose.prod.yml +85 -0
- package/dist/skills/agents-onboarding/templates/langfuse/.env.example +59 -0
- package/dist/skills/agents-onboarding/templates/langfuse/README.md +132 -0
- package/dist/skills/agents-onboarding/templates/langfuse/docker-compose.coolify.yml +189 -0
- package/dist/skills/agents-onboarding/templates/langfuse/docker-compose.yml +185 -0
- package/dist/skills/agents-operation/SKILL.md +42 -0
- package/dist/skills/agents-operation/gotchas.md +61 -0
- package/dist/skills/agents-operation/guardrails.md +26 -0
- package/dist/skills/agents-operation/references/00-production-safety.md +24 -0
- package/dist/skills/agents-operation/references/01-diagnose.md +34 -0
- package/dist/skills/agents-operation/references/02-reproduce.md +22 -0
- package/dist/skills/agents-operation/references/03-adjust.md +36 -0
- package/dist/skills/agents-operation/references/04-validate-and-apply.md +31 -0
- package/dist/ui-select.js +279 -0
- package/dist/ui.js +167 -0
- package/package.json +53 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Generic remote-bash runner for the fazer.ai agents onboarding. ONE job: run an arbitrary bash script on a
|
|
3
|
+
# host over SSH WITHOUT the agent hand-assembling it on the PowerShell command line. That assembly is the
|
|
4
|
+
# footgun that reappears every new context on Windows, in two distinct shapes (both seen in real runs):
|
|
5
|
+
#
|
|
6
|
+
# 1. Eaten quotes. `ssh <host> 'docker network ls --format "{{.Name}}" | grep -vE "^(a|b)$"'`, the inner
|
|
7
|
+
# double quotes are swallowed by Windows native-arg parsing on the way to ssh.exe, so the remote bash
|
|
8
|
+
# gets `{{.Name}}` / `^(a|b)$` UNQUOTED and dies with `syntax error near unexpected token '('`.
|
|
9
|
+
# 2. BOM on a here-string. `@'...set -euo pipefail...'@ | ssh ... 'bash -s'`, PowerShell prepends a UTF-8
|
|
10
|
+
# BOM to the piped text; the remote bash reads `set` as an unknown command on line 1, so the
|
|
11
|
+
# guard never arms and the rest of a (possibly destructive) script runs unguarded.
|
|
12
|
+
#
|
|
13
|
+
# Both vanish with the same move: write the script to a `.sh` file (with the editing tool, no shell in the
|
|
14
|
+
# loop) and feed it to the remote `bash -s` through THIS helper, which streams the bytes to ssh's stdin via
|
|
15
|
+
# a DIRECT argv list (no local shell). The script content never touches a command line, so quotes, `$()`,
|
|
16
|
+
# `{{...}}`, `(`, heredocs and newlines arrive byte-for-byte on every OS. Same payload-owning pattern as
|
|
17
|
+
# coolify.py (psql) and docker-status.py (`docker ps`), generalized to any script.
|
|
18
|
+
#
|
|
19
|
+
# remote.py --ssh root@HOST --ssh-opts "-i ~/.ssh/key" --script-file recon.sh # stream output
|
|
20
|
+
# remote.py --ssh root@HOST --ssh-opts "-i ~/.ssh/key" --script-file wipe.sh --sudo # via sudo -n
|
|
21
|
+
# remote.py --ssh ... --in-container coolify-db --exec "psql -U coolify -d coolify" --script-file q.sql # psql in a container
|
|
22
|
+
# remote.py --ssh ... --in-container <rails-c> --exec "bundle exec rails runner -" --script-file t.rb # rails runner
|
|
23
|
+
# remote.py --ssh root@HOST --ssh-opts "-i ~/.ssh/key" --script-file x.sh --capture # JSON result
|
|
24
|
+
# remote.py --ssh root@HOST --ssh-opts "-i ~/.ssh/key" --script-file x.sh --dry-run # argv+preview
|
|
25
|
+
#
|
|
26
|
+
# Same payload-on-stdin trick for a console INSIDE a container: psql and `rails runner -` read their program
|
|
27
|
+
# from stdin, so --in-container/--exec delivers a .sql/.rb file byte-exact too (the `\` of a PHP/Ruby
|
|
28
|
+
# namespace, accents, quotes, none of it touches a command line).
|
|
29
|
+
#
|
|
30
|
+
# Python 3 stdlib only (no pip). Runs ssh via Bash with dangerouslyDisableSandbox:true (it is network),
|
|
31
|
+
# same as sshkey.py/docker-status.py. Output streams live by default (long installs do not look hung and
|
|
32
|
+
# do not trip the harness timeout); exit code is propagated. --capture buffers and returns JSON instead.
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import shlex
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
|
|
41
|
+
BOM = b"\xef\xbb\xbf"
|
|
42
|
+
# Container name is interpolated into the remote command, so keep it to a safe bare token.
|
|
43
|
+
SAFE_TOKEN = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-")
|
|
44
|
+
# --exec is the in-container program that reads the script from stdin (psql / rails runner -). Allow only
|
|
45
|
+
# bare words, flags and the punctuation those need, no shell metacharacters that the remote shell would act on.
|
|
46
|
+
EXEC_OK = re.compile(r"^[A-Za-z0-9 _=:./-]+$")
|
|
47
|
+
# Git Bash/MSYS on Windows reports paths as "/c/Users/..." (or "/mnt/c/..." under WSL), but native Python
|
|
48
|
+
# on Windows cannot open those: it needs "C:\Users\...". Match a leading drive segment so we can retranslate.
|
|
49
|
+
MSYS_DRIVE_RE = re.compile(r"^/(?:mnt/)?([A-Za-z])/(.*)$")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def out(obj, code=0):
|
|
53
|
+
print(json.dumps(obj))
|
|
54
|
+
sys.exit(code)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fail(msg, **extra):
|
|
58
|
+
out({"ok": False, "error": msg, **extra}, code=1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def msys_to_native(path):
|
|
62
|
+
# Translate a Git Bash/MSYS/WSL path ("/c/Users/me/x.sh", "/mnt/c/Users/me/x.sh") to the native Windows
|
|
63
|
+
# form ("C:\Users\me\x.sh") so open() finds it. Returns None when the path is not drive-prefixed (nothing
|
|
64
|
+
# to translate). POSIX paths and already-native Windows paths ("C:\...", "C:/...") are left to open() as-is.
|
|
65
|
+
m = MSYS_DRIVE_RE.match(path)
|
|
66
|
+
if not m:
|
|
67
|
+
return None
|
|
68
|
+
drive, rest = m.group(1), m.group(2)
|
|
69
|
+
return f"{drive.upper()}:\\" + rest.replace("/", "\\")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def split_ssh_opts(opts, _nt=None):
|
|
73
|
+
# POSIX shlex eats backslashes, so a Windows key path ("-i C:\Users\me\.ssh\key") would arrive as
|
|
74
|
+
# "C:Usersme.sshkey". On Windows, tokenize WITHOUT escape processing and strip the surrounding quotes
|
|
75
|
+
# ourselves so backslashes survive. _nt is injectable for tests. (Kept in sync with docker-status.py.)
|
|
76
|
+
nt = (os.name == "nt") if _nt is None else _nt
|
|
77
|
+
if not opts:
|
|
78
|
+
return []
|
|
79
|
+
if nt:
|
|
80
|
+
toks = shlex.split(opts, posix=False)
|
|
81
|
+
return [t[1:-1] if len(t) >= 2 and t[0] == t[-1] and t[0] in "\"'" else t for t in toks]
|
|
82
|
+
return shlex.split(opts)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read_bytes(path):
|
|
86
|
+
with open(path, "rb") as f:
|
|
87
|
+
return f.read()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_script(path):
|
|
91
|
+
if path == "-":
|
|
92
|
+
data = sys.stdin.buffer.read()
|
|
93
|
+
else:
|
|
94
|
+
try:
|
|
95
|
+
data = _read_bytes(path)
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
# A Git Bash/MSYS path ("/c/Users/me/x.sh") reaches native Windows Python verbatim and fails to
|
|
98
|
+
# open; retry the translated native form ("C:\Users\me\x.sh") before giving up.
|
|
99
|
+
native = msys_to_native(path)
|
|
100
|
+
if native is None:
|
|
101
|
+
fail(f"cannot read --script-file {path!r}: {exc}")
|
|
102
|
+
try:
|
|
103
|
+
data = _read_bytes(native)
|
|
104
|
+
except OSError as exc2:
|
|
105
|
+
fail(f"cannot read --script-file {path!r} (tried {native!r}): {exc2}")
|
|
106
|
+
# Strip a stray UTF-8 BOM defensively: a PowerShell here-string / `Out-File` adds one and it breaks the
|
|
107
|
+
# script's first line (the exact failure this helper exists to remove). We deliver the rest byte-exact.
|
|
108
|
+
if data[:3] == BOM:
|
|
109
|
+
data = data[3:]
|
|
110
|
+
if not data.strip():
|
|
111
|
+
fail("script is empty")
|
|
112
|
+
return data
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_argv(args, remote):
|
|
116
|
+
return [
|
|
117
|
+
"ssh", "-o", "BatchMode=yes", "-o", f"ConnectTimeout={args.connect_timeout}",
|
|
118
|
+
"-o", "StrictHostKeyChecking=accept-new", *split_ssh_opts(args.ssh_opts), args.ssh, remote,
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def remote_command(args):
|
|
123
|
+
# The remote command carries NO payload; it just starts the interpreter; the script rides in on stdin,
|
|
124
|
+
# so it never hits a command line. `bash -s` and `docker exec -i <c> <prog>` (psql, `rails runner -`)
|
|
125
|
+
# all read their program from stdin. `sudo -n` fails fast instead of hanging on a password prompt.
|
|
126
|
+
if args.in_container:
|
|
127
|
+
if any(c not in SAFE_TOKEN for c in args.in_container):
|
|
128
|
+
fail(f"invalid --in-container {args.in_container!r} (expected a bare [A-Za-z0-9._-] token)")
|
|
129
|
+
inner = args.exec or "bash -s"
|
|
130
|
+
if not EXEC_OK.match(inner):
|
|
131
|
+
fail(f"invalid --exec {inner!r} (bare words/flags only: [A-Za-z0-9 _=:./-])")
|
|
132
|
+
base = f"docker exec -i {args.in_container} {inner}"
|
|
133
|
+
else:
|
|
134
|
+
base = "bash -s"
|
|
135
|
+
return f"sudo -n -- {base}" if args.sudo else base
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_run(args):
|
|
139
|
+
script = read_script(args.script_file)
|
|
140
|
+
remote = remote_command(args)
|
|
141
|
+
argv = build_argv(args, remote)
|
|
142
|
+
if args.dry_run:
|
|
143
|
+
preview = script[:400].decode("utf-8", "replace")
|
|
144
|
+
out({
|
|
145
|
+
"ok": True, "dry_run": True, "remote_cmd": remote, "argv": argv,
|
|
146
|
+
"script_bytes": len(script), "script_preview": preview,
|
|
147
|
+
})
|
|
148
|
+
timeout = args.timeout or None
|
|
149
|
+
try:
|
|
150
|
+
if args.capture:
|
|
151
|
+
proc = subprocess.run(argv, input=script, capture_output=True, timeout=timeout)
|
|
152
|
+
ok = proc.returncode == 0
|
|
153
|
+
out({
|
|
154
|
+
"ok": ok, "exit_code": proc.returncode,
|
|
155
|
+
"stdout": proc.stdout.decode("utf-8", "replace"),
|
|
156
|
+
"stderr": proc.stderr.decode("utf-8", "replace"),
|
|
157
|
+
}, code=0 if ok else 1)
|
|
158
|
+
# Stream mode: inherit stdout/stderr so the agent sees output live; feed the script on stdin.
|
|
159
|
+
proc = subprocess.run(argv, input=script, timeout=timeout)
|
|
160
|
+
sys.exit(proc.returncode)
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
fail("ssh not found on PATH")
|
|
163
|
+
except subprocess.TimeoutExpired:
|
|
164
|
+
fail("ssh timed out", remote_cmd=remote)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_parser():
|
|
168
|
+
parser = argparse.ArgumentParser(
|
|
169
|
+
prog="remote.py",
|
|
170
|
+
description="Run an arbitrary bash script on a host over SSH (payload-owning; survives "
|
|
171
|
+
"PowerShell->SSH). Write the script to a .sh file and point --script-file at it.",
|
|
172
|
+
)
|
|
173
|
+
parser.add_argument("--ssh", required=True, metavar="USER@HOST")
|
|
174
|
+
parser.add_argument("--ssh-opts", default="", help="extra ssh options, e.g. '-i ~/.ssh/key -p 2222'")
|
|
175
|
+
parser.add_argument("--script-file", required=True, metavar="PATH",
|
|
176
|
+
help="path to the bash script to run remotely ('-' reads stdin; avoid '-' on PowerShell)")
|
|
177
|
+
parser.add_argument("--in-container", default="", metavar="NAME",
|
|
178
|
+
help="pipe the script into `docker exec -i NAME ...` on the host (console in a container)")
|
|
179
|
+
parser.add_argument("--exec", default="", metavar="CMD",
|
|
180
|
+
help="program inside --in-container that reads the script on stdin, e.g. "
|
|
181
|
+
"'psql -U coolify -d coolify -v ON_ERROR_STOP=1' or 'bundle exec rails runner -' (default: bash -s)")
|
|
182
|
+
parser.add_argument("--sudo", action="store_true", help="run via `sudo -n -- …` (works with bash -s and --in-container)")
|
|
183
|
+
parser.add_argument("--capture", action="store_true",
|
|
184
|
+
help="buffer and print {ok, exit_code, stdout, stderr} as JSON instead of streaming")
|
|
185
|
+
parser.add_argument("--connect-timeout", type=int, default=12)
|
|
186
|
+
parser.add_argument("--timeout", type=int, default=0, help="ssh timeout in seconds (0 = no limit; installs run long)")
|
|
187
|
+
parser.add_argument("--dry-run", action="store_true", help="print the argv + script preview, do not connect")
|
|
188
|
+
parser.set_defaults(fn=cmd_run)
|
|
189
|
+
return parser
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main():
|
|
193
|
+
args = build_parser().parse_args()
|
|
194
|
+
args.fn(args)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
main()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SSH key helper for the fazer.ai agents onboarding. Two jobs:
|
|
3
|
+
# generate create a dedicated ed25519 key and print the PUBLIC half for the operator to paste in the
|
|
4
|
+
# VPS panel. ssh-keygen is spawned with a DIRECT argv list (no shell) so the empty passphrase
|
|
5
|
+
# `-N ""` survives on every OS. Driven through a shell line, PowerShell DROPS the empty-string
|
|
6
|
+
# arg, ssh-keygen then falls into its interactive passphrase prompt and HANGS, exactly what
|
|
7
|
+
# cost a Windows run six failed attempts and a timeout. argv-direct sidesteps the whole class.
|
|
8
|
+
# wait-access poll SSH until the freshly pasted key logs in, so the agent detects "done" itself instead
|
|
9
|
+
# of asking the operator to come back and confirm. Timeout -> exit 1 (caller falls back to ask).
|
|
10
|
+
# Python 3 stdlib only (no pip). `generate` is local; `wait-access` runs ssh via Bash with
|
|
11
|
+
# dangerouslyDisableSandbox:true (it is network), same as the other helpers.
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def out(obj, code=0):
|
|
26
|
+
print(json.dumps(obj))
|
|
27
|
+
sys.exit(code)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fail(msg, **extra):
|
|
31
|
+
out({"ok": False, "error": msg, **extra}, code=1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def split_ssh_opts(opts, _nt=None):
|
|
35
|
+
# POSIX shlex eats backslashes, so a Windows key path ("-i C:\Users\me\.ssh\key") would arrive as
|
|
36
|
+
# "C:Usersme.sshkey" (a real onboarding failure). On Windows, tokenize WITHOUT escape processing and
|
|
37
|
+
# strip the surrounding quotes ourselves so backslashes are preserved. _nt is injectable for tests.
|
|
38
|
+
nt = (os.name == "nt") if _nt is None else _nt
|
|
39
|
+
if not opts:
|
|
40
|
+
return []
|
|
41
|
+
if nt:
|
|
42
|
+
toks = shlex.split(opts, posix=False)
|
|
43
|
+
return [t[1:-1] if len(t) >= 2 and t[0] == t[-1] and t[0] in "\"'" else t for t in toks]
|
|
44
|
+
return shlex.split(opts)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_generate(args):
|
|
48
|
+
if not NAME_RE.match(args.name):
|
|
49
|
+
fail(f"invalid --name {args.name!r} (expected a bare filename [A-Za-z0-9._-]+, no path)")
|
|
50
|
+
ssh_dir = Path(args.ssh_dir).expanduser() if args.ssh_dir else Path.home() / ".ssh"
|
|
51
|
+
try:
|
|
52
|
+
ssh_dir.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
os.chmod(ssh_dir, 0o700)
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
fail(f"cannot prepare ssh dir {ssh_dir}: {exc}")
|
|
56
|
+
key = ssh_dir / args.name
|
|
57
|
+
pub = Path(str(key) + ".pub")
|
|
58
|
+
existed = key.exists() and pub.exists()
|
|
59
|
+
if not existed:
|
|
60
|
+
# argv-direct: the empty passphrase "" is a real, separate argv element on every OS here.
|
|
61
|
+
argv = ["ssh-keygen", "-t", "ed25519", "-f", str(key), "-N", "", "-C", args.comment]
|
|
62
|
+
try:
|
|
63
|
+
proc = subprocess.run(argv, capture_output=True, text=True, timeout=args.timeout)
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
fail("ssh-keygen not found on PATH (install OpenSSH client)")
|
|
66
|
+
except subprocess.TimeoutExpired:
|
|
67
|
+
fail("ssh-keygen timed out (interactive prompt? the empty passphrase did not pass through)")
|
|
68
|
+
if proc.returncode != 0 or not pub.exists():
|
|
69
|
+
fail("ssh-keygen failed", exit_code=proc.returncode, stderr=(proc.stderr or "")[-400:])
|
|
70
|
+
try:
|
|
71
|
+
os.chmod(key, 0o600)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
try:
|
|
75
|
+
public_key = pub.read_text(encoding="utf-8").strip()
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
fail(f"key generated but cannot read public half: {exc}")
|
|
78
|
+
out({"ok": True, "key_path": str(key), "public_key": public_key, "existed": existed})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_wait_access(args):
|
|
82
|
+
argv = [
|
|
83
|
+
"ssh", "-o", "BatchMode=yes", "-o", f"ConnectTimeout={args.connect_timeout}",
|
|
84
|
+
"-o", "StrictHostKeyChecking=accept-new", *split_ssh_opts(args.ssh_opts), args.ssh, "echo OK",
|
|
85
|
+
]
|
|
86
|
+
last = None
|
|
87
|
+
for i in range(args.attempts):
|
|
88
|
+
try:
|
|
89
|
+
proc = subprocess.run(argv, capture_output=True, text=True, timeout=args.timeout)
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
fail("ssh not found on PATH")
|
|
92
|
+
except subprocess.TimeoutExpired:
|
|
93
|
+
proc = None
|
|
94
|
+
if proc is not None and proc.returncode == 0 and "OK" in (proc.stdout or ""):
|
|
95
|
+
out({"ok": True, "reachable": True, "attempt": i + 1})
|
|
96
|
+
if proc is not None:
|
|
97
|
+
last = (proc.stderr or proc.stdout or "").strip()[-200:]
|
|
98
|
+
if i < args.attempts - 1:
|
|
99
|
+
time.sleep(args.interval)
|
|
100
|
+
out(
|
|
101
|
+
{"ok": False, "reachable": False, "attempts": args.attempts, "last_error": last,
|
|
102
|
+
"note": "key not active yet: confirm it is pasted in the VPS panel, or ask the operator"},
|
|
103
|
+
code=1,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_parser():
|
|
108
|
+
parser = argparse.ArgumentParser(
|
|
109
|
+
prog="sshkey.py",
|
|
110
|
+
description="Generate a dedicated ed25519 key (argv-direct, empty passphrase safe on Windows) "
|
|
111
|
+
"and poll SSH access.",
|
|
112
|
+
)
|
|
113
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
114
|
+
|
|
115
|
+
gen = sub.add_parser("generate", help="create ~/.ssh/<name> ed25519 (idempotent) and print the public key")
|
|
116
|
+
gen.add_argument("--name", required=True, help="bare key filename; use the fixed 'fazer-ai-agents' so re-runs reuse the same key (no per-run suffix)")
|
|
117
|
+
gen.add_argument("--comment", default="fazer-ai-onboarding", help="ssh-keygen -C comment")
|
|
118
|
+
gen.add_argument("--ssh-dir", default="", help="override the .ssh dir (default ~/.ssh; for tests)")
|
|
119
|
+
gen.add_argument("--timeout", type=int, default=30)
|
|
120
|
+
gen.set_defaults(fn=cmd_generate)
|
|
121
|
+
|
|
122
|
+
wait = sub.add_parser("wait-access", help="poll SSH until the pasted key logs in (else exit 1)")
|
|
123
|
+
wait.add_argument("--ssh", required=True, metavar="USER@HOST")
|
|
124
|
+
wait.add_argument("--ssh-opts", default="", help="extra ssh options, e.g. '-i ~/.ssh/key -p 2222'")
|
|
125
|
+
wait.add_argument("--attempts", type=int, default=30, help="poll attempts (default 30)")
|
|
126
|
+
wait.add_argument("--interval", type=int, default=5, help="seconds between attempts (default 5)")
|
|
127
|
+
wait.add_argument("--connect-timeout", type=int, default=12)
|
|
128
|
+
wait.add_argument("--timeout", type=int, default=20, help="per-attempt ssh timeout")
|
|
129
|
+
wait.set_defaults(fn=cmd_wait_access)
|
|
130
|
+
|
|
131
|
+
return parser
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main():
|
|
135
|
+
args = build_parser().parse_args()
|
|
136
|
+
args.fn(args)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
main()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Chatwoot (generic flavor): copy to .env and fill every CHANGE_ME. Do NOT commit the filled .env.
|
|
2
|
+
# Generate secrets with: openssl rand -hex 64 (SECRET_KEY_BASE) / openssl rand -hex 24 (passwords)
|
|
3
|
+
|
|
4
|
+
# Public URL (where your reverse proxy serves Chatwoot). Required.
|
|
5
|
+
CHATWOOT_URL=https://chatwoot.example.com
|
|
6
|
+
CHATWOOT_LOCALE=pt_BR
|
|
7
|
+
|
|
8
|
+
# Postgres (this stack's own DB; pgvector image).
|
|
9
|
+
POSTGRES_DB=chatwoot_production
|
|
10
|
+
POSTGRES_USER=chatwoot
|
|
11
|
+
POSTGRES_PASSWORD=CHANGE_ME_pg_password
|
|
12
|
+
|
|
13
|
+
# Rails secret (128 hex chars: openssl rand -hex 64).
|
|
14
|
+
SECRET_KEY_BASE=CHANGE_ME_secret_key_base
|
|
15
|
+
|
|
16
|
+
# Redis password (openssl rand -hex 24).
|
|
17
|
+
REDIS_PASSWORD=CHANGE_ME_redis_password
|
|
18
|
+
|
|
19
|
+
# Host bind for the web port (front it with your own TLS proxy). Default localhost-only.
|
|
20
|
+
CHATWOOT_PORT=127.0.0.1:3001
|
|
21
|
+
|
|
22
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
# EDITION SELECTION
|
|
24
|
+
# OSS (default, no hub subscription): leave CHATWOOT_IMAGE unset (uses our ghcr.io/fazer-ai/chatwoot fork), do NOT set COMPOSE_PROFILES.
|
|
25
|
+
# Pro (hub subscription): uncomment BOTH lines below, then `docker login harbor.fazer.ai`
|
|
26
|
+
# (hub: generate_install_script reveals the credential) BEFORE deploying. Pro adds Baileys WhatsApp.
|
|
27
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
# CHATWOOT_IMAGE=harbor.fazer.ai/chatwoot/fazer-ai/chatwoot-pro:latest
|
|
29
|
+
# COMPOSE_PROFILES=pro
|
|
30
|
+
# BAILEYS_DEFAULT_API_KEY=CHANGE_ME_baileys_key # required only for Pro (openssl rand -hex 24)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Chatwoot (self-hosted) for fazer.ai
|
|
2
|
+
|
|
3
|
+
The inbox fazer.ai agents plugs into. The onboarding installs Chatwoot as part of the journey, in one of
|
|
4
|
+
**two editions**, chosen by whether the operator has a subscription on the fazer.ai hub:
|
|
5
|
+
|
|
6
|
+
| Edition | Image | When | Extra features |
|
|
7
|
+
| --- | --- | --- | --- |
|
|
8
|
+
| **Pro** | `harbor.fazer.ai/chatwoot/fazer-ai/chatwoot-pro` (private Harbor) | operator **has a hub license** | Baileys WhatsApp provider, Kanban |
|
|
9
|
+
| **OSS** | `ghcr.io/fazer-ai/chatwoot` (our public fork) | **no** hub subscription | standard Chatwoot (official WhatsApp Cloud API, etc.) |
|
|
10
|
+
|
|
11
|
+
**Both editions work with fazer.ai agents**: the integration is the standard Chatwoot Agent Bot + API
|
|
12
|
+
(see `docs/chatwoot.md`). OSS is **not** a downgrade of compatibility, only of
|
|
13
|
+
Pro-exclusive channels/features.
|
|
14
|
+
|
|
15
|
+
**Edition source (in order).** The onboarding CLI captures the operator's choice up front and writes it to
|
|
16
|
+
`~/.fazer-ai/onboarding.json` (`{ "chatwootTier": "pro" | "community", "chatwootLicenseId": "<id>" }`).
|
|
17
|
+
Read that marker **first**: `"pro"` → deploy the Pro image and license it at step 9b (use
|
|
18
|
+
`chatwootLicenseId`); `"community"` → deploy OSS (the operator deliberately chose to skip Pro). Only if the
|
|
19
|
+
marker is **absent** (token fallback or a non-CLI entry point) fall back to the hub (`list_licenses` /
|
|
20
|
+
`whoami`): a `CHATWOOT` license → Pro; otherwise OSS. The marker wins because "proceed without Pro" is an
|
|
21
|
+
intent the hub can't express: an operator may own a license yet still pick OSS for this box.
|
|
22
|
+
|
|
23
|
+
## Files
|
|
24
|
+
|
|
25
|
+
| File | Use |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `docker-compose.yml` | **Generic**: Portainer / EasyPanel / Dokploy / plain Docker. ONE file, both editions via env. |
|
|
28
|
+
| `docker-compose.coolify.yml` | **Coolify** (magic vars; secrets auto-generated). Pro image by default. |
|
|
29
|
+
| `.env.example` | Template for the generic flavor. `cp .env.example .env`, fill every `CHANGE_ME`. |
|
|
30
|
+
|
|
31
|
+
Topology (both): `chatwoot` (web) + `sidekiq` + `postgres` (pgvector) + `redis`, plus `baileys-api`
|
|
32
|
+
**only in Pro** (compose profile `pro`).
|
|
33
|
+
|
|
34
|
+
## Deploy (generic: Portainer / plain Docker)
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
cp .env.example .env # fill every CHANGE_ME (openssl rand -hex 64 / -hex 24)
|
|
38
|
+
|
|
39
|
+
# OSS (no subscription):
|
|
40
|
+
docker compose up -d
|
|
41
|
+
|
|
42
|
+
# Pro (hub subscription): authenticate to Harbor first, then enable the pro profile + image.
|
|
43
|
+
docker login harbor.fazer.ai # username + secret from the hub (generate_install_script)
|
|
44
|
+
CHATWOOT_IMAGE=harbor.fazer.ai/chatwoot/fazer-ai/chatwoot-pro:latest \
|
|
45
|
+
COMPOSE_PROFILES=pro docker compose up -d
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Portainer**: paste `docker-compose.yml` as a Stack and provide the same variables in the Stack env
|
|
49
|
+
editor (for Pro, add `CHATWOOT_IMAGE` + `COMPOSE_PROFILES=pro` and register the Harbor credential under
|
|
50
|
+
Registries first, or `Registries:[<id>]` in the API). Front `chatwoot.<domain>` with a TLS proxy: the
|
|
51
|
+
fazer.ai agents [`docker-compose.portainer.yml`](../docker-compose.portainer.yml) bundled Caddy can do
|
|
52
|
+
this (point a Caddy site at the published `CHATWOOT_PORT`); see the `agents-onboarding` skill (`references/deploy-b-portainer.md`).
|
|
53
|
+
|
|
54
|
+
## Wire into fazer.ai agents
|
|
55
|
+
|
|
56
|
+
After both are up (see `docs/chatwoot.md`): create a Chatwoot admin, mint an
|
|
57
|
+
admin token, then from the agents register the deployment (`POST /v1/chatwoot/deployment {baseUrl, adminToken}`),
|
|
58
|
+
sync the account inbox, and bind an inbox to an agent (`PATCH /v1/chatwoot/inboxes/:id {agentId}`), which
|
|
59
|
+
auto-provisions the Agent Bot. The bind is identical for Pro and OSS.
|
|
60
|
+
|
|
61
|
+
## First admin
|
|
62
|
+
|
|
63
|
+
In the real flow the operator creates the first Chatwoot admin **in the browser** at `CHATWOOT_URL/app`.
|
|
64
|
+
The agent never creates the account or the admin; it waits for the operator, then reads the admin token
|
|
65
|
+
(`scripts/chatwoot-admin.py provision`, Rails runner read-only) to connect the deployment.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Chatwoot for fazer.ai: COOLIFY flavor (magic vars; Coolify auto-generates the SERVICE_* secrets).
|
|
2
|
+
# Edition by CHATWOOT_IMAGE: default = Pro (private Harbor image); for OSS set
|
|
3
|
+
# CHATWOOT_IMAGE=ghcr.io/fazer-ai/chatwoot:latest (our public fork) AND drop the baileys-api service.
|
|
4
|
+
# Pro pull needs the Harbor registry credential configured in Coolify (hub: per-user credential); OSS needs none.
|
|
5
|
+
# Set the service Domain in Coolify to your Chatwoot FQDN (fills SERVICE_URL_CHATWOOT).
|
|
6
|
+
# This is the compose validated in the RUN 2 Coolify deploy; the generic/Portainer flavor is ./docker-compose.yml.
|
|
7
|
+
|
|
8
|
+
services:
|
|
9
|
+
chatwoot:
|
|
10
|
+
image: '${CHATWOOT_IMAGE:-harbor.fazer.ai/chatwoot/fazer-ai/chatwoot-pro:latest}'
|
|
11
|
+
pull_policy: always
|
|
12
|
+
volumes:
|
|
13
|
+
- 'storage:/app/storage'
|
|
14
|
+
depends_on:
|
|
15
|
+
- postgres
|
|
16
|
+
- redis
|
|
17
|
+
environment:
|
|
18
|
+
- SERVICE_URL_CHATWOOT
|
|
19
|
+
- NODE_ENV=production
|
|
20
|
+
- RAILS_ENV=production
|
|
21
|
+
- INSTALLATION_ENV=docker
|
|
22
|
+
- DEFAULT_LOCALE=pt_BR
|
|
23
|
+
- LOG_LEVEL=${RAILS_LOG_LEVEL:-info}
|
|
24
|
+
- 'FRONTEND_URL=${SERVICE_URL_CHATWOOT}'
|
|
25
|
+
- 'INTERNAL_HOST_URL=http://chatwoot:3000'
|
|
26
|
+
- POSTGRES_HOST=postgres
|
|
27
|
+
- POSTGRES_PORT=5432
|
|
28
|
+
- 'POSTGRES_USERNAME=${SERVICE_USER_POSTGRES}'
|
|
29
|
+
- 'POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}'
|
|
30
|
+
- 'POSTGRES_DATABASE=${POSTGRES_DB:-chatwoot_production}'
|
|
31
|
+
- 'SECRET_KEY_BASE=${SERVICE_PASSWORD_64_SECRETKEYBASE}'
|
|
32
|
+
- 'REDIS_URL=redis://redis:6379'
|
|
33
|
+
- 'REDIS_PASSWORD=${SERVICE_PASSWORD_REDIS}'
|
|
34
|
+
- 'BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=${BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME:-Chatwoot}'
|
|
35
|
+
- 'BAILEYS_PROVIDER_DEFAULT_URL=http://baileys-api:3025'
|
|
36
|
+
- 'BAILEYS_PROVIDER_DEFAULT_API_KEY=${SERVICE_PASSWORD_64_DEFAULTAPIKEY}'
|
|
37
|
+
- BAILEYS_PROVIDER_USE_INTERNAL_HOST_URL=true
|
|
38
|
+
- 'MAILER_SENDER_EMAIL=${MAILER_SENDER_EMAIL}'
|
|
39
|
+
- 'RESEND_API_KEY=${RESEND_API_KEY}'
|
|
40
|
+
entrypoint: docker/entrypoints/rails.sh
|
|
41
|
+
command:
|
|
42
|
+
- sh
|
|
43
|
+
- '-c'
|
|
44
|
+
- 'bundle exec rails db:chatwoot_prepare && bundle exec rails branding:update && exec bundle exec rails s -p 3000 -b 0.0.0.0'
|
|
45
|
+
restart: always
|
|
46
|
+
healthcheck:
|
|
47
|
+
test: ['CMD-SHELL', 'wget -qO- --header="Accept: text/html" http://127.0.0.1:3000/']
|
|
48
|
+
interval: 20s
|
|
49
|
+
timeout: 10s
|
|
50
|
+
retries: 5
|
|
51
|
+
sidekiq:
|
|
52
|
+
image: '${CHATWOOT_IMAGE:-harbor.fazer.ai/chatwoot/fazer-ai/chatwoot-pro:latest}'
|
|
53
|
+
pull_policy: always
|
|
54
|
+
volumes:
|
|
55
|
+
- 'storage:/app/storage'
|
|
56
|
+
depends_on:
|
|
57
|
+
- postgres
|
|
58
|
+
- redis
|
|
59
|
+
environment:
|
|
60
|
+
- NODE_ENV=production
|
|
61
|
+
- RAILS_ENV=production
|
|
62
|
+
- INSTALLATION_ENV=docker
|
|
63
|
+
- DEFAULT_LOCALE=pt_BR
|
|
64
|
+
- LOG_LEVEL=${RAILS_LOG_LEVEL:-info}
|
|
65
|
+
- 'FRONTEND_URL=${SERVICE_URL_CHATWOOT}'
|
|
66
|
+
- 'INTERNAL_HOST_URL=http://chatwoot:3000'
|
|
67
|
+
- POSTGRES_HOST=postgres
|
|
68
|
+
- POSTGRES_PORT=5432
|
|
69
|
+
- 'POSTGRES_USERNAME=${SERVICE_USER_POSTGRES}'
|
|
70
|
+
- 'POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}'
|
|
71
|
+
- 'POSTGRES_DATABASE=${POSTGRES_DB:-chatwoot_production}'
|
|
72
|
+
- 'SECRET_KEY_BASE=${SERVICE_PASSWORD_64_SECRETKEYBASE}'
|
|
73
|
+
- 'REDIS_URL=redis://redis:6379'
|
|
74
|
+
- 'REDIS_PASSWORD=${SERVICE_PASSWORD_REDIS}'
|
|
75
|
+
- 'BAILEYS_PROVIDER_DEFAULT_URL=http://baileys-api:3025'
|
|
76
|
+
- 'BAILEYS_PROVIDER_DEFAULT_API_KEY=${SERVICE_PASSWORD_64_DEFAULTAPIKEY}'
|
|
77
|
+
- BAILEYS_PROVIDER_USE_INTERNAL_HOST_URL=true
|
|
78
|
+
- 'MAILER_SENDER_EMAIL=${MAILER_SENDER_EMAIL}'
|
|
79
|
+
- 'RESEND_API_KEY=${RESEND_API_KEY}'
|
|
80
|
+
command: ['bundle', 'exec', 'sidekiq', '-C', 'config/sidekiq.yml']
|
|
81
|
+
restart: always
|
|
82
|
+
healthcheck:
|
|
83
|
+
test: ['CMD-SHELL', 'ps aux | grep [s]idekiq']
|
|
84
|
+
interval: 20s
|
|
85
|
+
timeout: 10s
|
|
86
|
+
retries: 5
|
|
87
|
+
postgres:
|
|
88
|
+
image: 'ghcr.io/fazer-ai/pgvector:16'
|
|
89
|
+
restart: always
|
|
90
|
+
volumes:
|
|
91
|
+
- 'postgres:/var/lib/postgresql/data'
|
|
92
|
+
environment:
|
|
93
|
+
- 'POSTGRES_DB=${POSTGRES_DB:-chatwoot_production}'
|
|
94
|
+
- 'POSTGRES_USER=${SERVICE_USER_POSTGRES}'
|
|
95
|
+
- 'POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}'
|
|
96
|
+
healthcheck:
|
|
97
|
+
test: ['CMD-SHELL', 'pg_isready -h localhost -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
|
|
98
|
+
interval: 20s
|
|
99
|
+
timeout: 20s
|
|
100
|
+
retries: 10
|
|
101
|
+
redis:
|
|
102
|
+
image: 'redis:alpine'
|
|
103
|
+
restart: always
|
|
104
|
+
command: ['sh', '-c', 'redis-server --requirepass "${SERVICE_PASSWORD_REDIS}"']
|
|
105
|
+
volumes:
|
|
106
|
+
- 'redis:/data'
|
|
107
|
+
environment:
|
|
108
|
+
- 'REDIS_PASSWORD=${SERVICE_PASSWORD_REDIS}'
|
|
109
|
+
healthcheck:
|
|
110
|
+
test: ['CMD-SHELL', 'redis-cli -h localhost -p 6379 -a $${REDIS_PASSWORD} ping']
|
|
111
|
+
interval: 20s
|
|
112
|
+
timeout: 20s
|
|
113
|
+
retries: 10
|
|
114
|
+
baileys-api:
|
|
115
|
+
image: 'ghcr.io/fazer-ai/baileys-api:latest'
|
|
116
|
+
pull_policy: always
|
|
117
|
+
volumes:
|
|
118
|
+
- 'storage:/app/storage'
|
|
119
|
+
environment:
|
|
120
|
+
- NODE_ENV=production
|
|
121
|
+
- 'REDIS_URL=redis://redis:6379'
|
|
122
|
+
- 'REDIS_PASSWORD=${SERVICE_PASSWORD_REDIS}'
|
|
123
|
+
- 'LOG_LEVEL=${BAILEYS_API_LOG_LEVEL:-info}'
|
|
124
|
+
- 'BAILEYS_PROVIDER_DEFAULT_API_KEY=${SERVICE_PASSWORD_64_DEFAULTAPIKEY}'
|
|
125
|
+
command: ['sh', '-c', 'bun manage-api-keys create user ${SERVICE_PASSWORD_64_DEFAULTAPIKEY} && bun start']
|
|
126
|
+
restart: always
|
|
127
|
+
healthcheck:
|
|
128
|
+
test: ['CMD-SHELL', 'wget -qO- http://localhost:3025/status']
|
|
129
|
+
interval: 20s
|
|
130
|
+
timeout: 20s
|
|
131
|
+
retries: 10
|
|
132
|
+
|
|
133
|
+
volumes:
|
|
134
|
+
storage:
|
|
135
|
+
postgres:
|
|
136
|
+
redis:
|