@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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/agents/claude.js +152 -0
  4. package/dist/agents/codex.js +155 -0
  5. package/dist/agents/detect.js +15 -0
  6. package/dist/agents/handoff.js +22 -0
  7. package/dist/agents/hermes-skills.js +177 -0
  8. package/dist/agents/hermes.js +474 -0
  9. package/dist/agents/index.js +57 -0
  10. package/dist/agents/manual.js +22 -0
  11. package/dist/agents/other.js +39 -0
  12. package/dist/agents/shell.js +15 -0
  13. package/dist/agents/types.js +2 -0
  14. package/dist/config.js +48 -0
  15. package/dist/exec.js +279 -0
  16. package/dist/hostinger.js +75 -0
  17. package/dist/hub-command.js +144 -0
  18. package/dist/index.js +726 -0
  19. package/dist/licenses.js +93 -0
  20. package/dist/mcp.js +100 -0
  21. package/dist/oauth.js +578 -0
  22. package/dist/onboarding-marker.js +48 -0
  23. package/dist/preferences.js +40 -0
  24. package/dist/skills/agents-dev/SKILL.md +37 -0
  25. package/dist/skills/agents-dev/gotchas.md +6 -0
  26. package/dist/skills/agents-dev/guardrails.md +6 -0
  27. package/dist/skills/agents-dev/references/00-get-the-code.md +28 -0
  28. package/dist/skills/agents-dev/references/01-layout-and-bun-check.md +29 -0
  29. package/dist/skills/agents-dev/references/02-free-full-and-invariants.md +7 -0
  30. package/dist/skills/agents-dev/references/03-implement.md +9 -0
  31. package/dist/skills/agents-dev/references/04-own-image-and-deploy.md +13 -0
  32. package/dist/skills/agents-onboarding/SKILL.md +80 -0
  33. package/dist/skills/agents-onboarding/gotchas.md +157 -0
  34. package/dist/skills/agents-onboarding/guardrails.md +65 -0
  35. package/dist/skills/agents-onboarding/references/00-prereqs-and-access.md +37 -0
  36. package/dist/skills/agents-onboarding/references/01-vps-dns-ssh.md +67 -0
  37. package/dist/skills/agents-onboarding/references/01b-brownfield.md +70 -0
  38. package/dist/skills/agents-onboarding/references/01c-pick-tier.md +61 -0
  39. package/dist/skills/agents-onboarding/references/02-coolify.md +109 -0
  40. package/dist/skills/agents-onboarding/references/03-chatwoot-pro.md +61 -0
  41. package/dist/skills/agents-onboarding/references/04-agents-image.md +46 -0
  42. package/dist/skills/agents-onboarding/references/05-langfuse.md +45 -0
  43. package/dist/skills/agents-onboarding/references/06-setup-and-mcp.md +47 -0
  44. package/dist/skills/agents-onboarding/references/08-agent-import.md +55 -0
  45. package/dist/skills/agents-onboarding/references/09-chatwoot-bind.md +41 -0
  46. package/dist/skills/agents-onboarding/references/10-validate-e2e.md +34 -0
  47. package/dist/skills/agents-onboarding/references/agent-features.md +61 -0
  48. package/dist/skills/agents-onboarding/references/chatwoot-hub-register.md +69 -0
  49. package/dist/skills/agents-onboarding/references/deploy-b-portainer.md +138 -0
  50. package/dist/skills/agents-onboarding/references/deploy-c-compose.md +64 -0
  51. package/dist/skills/agents-onboarding/samples/agents/README.md +23 -0
  52. package/dist/skills/agents-onboarding/samples/agents/maria-clinica-moreira.json +313 -0
  53. package/dist/skills/agents-onboarding/scripts/chatwoot-admin.py +248 -0
  54. package/dist/skills/agents-onboarding/scripts/coolify.py +552 -0
  55. package/dist/skills/agents-onboarding/scripts/docker-status.py +129 -0
  56. package/dist/skills/agents-onboarding/scripts/gen-onboarding-env.ts +187 -0
  57. package/dist/skills/agents-onboarding/scripts/harbor-login.py +118 -0
  58. package/dist/skills/agents-onboarding/scripts/langfuse-verify.py +118 -0
  59. package/dist/skills/agents-onboarding/scripts/portainer-brownfield.py +115 -0
  60. package/dist/skills/agents-onboarding/scripts/remote.py +198 -0
  61. package/dist/skills/agents-onboarding/scripts/sshkey.py +140 -0
  62. package/dist/skills/agents-onboarding/templates/chatwoot/.env.example +30 -0
  63. package/dist/skills/agents-onboarding/templates/chatwoot/README.md +65 -0
  64. package/dist/skills/agents-onboarding/templates/chatwoot/docker-compose.coolify.yml +136 -0
  65. package/dist/skills/agents-onboarding/templates/chatwoot/docker-compose.yml +139 -0
  66. package/dist/skills/agents-onboarding/templates/docker-compose.coolify.yml +73 -0
  67. package/dist/skills/agents-onboarding/templates/docker-compose.portainer.yml +132 -0
  68. package/dist/skills/agents-onboarding/templates/docker-compose.prod.yml +85 -0
  69. package/dist/skills/agents-onboarding/templates/langfuse/.env.example +59 -0
  70. package/dist/skills/agents-onboarding/templates/langfuse/README.md +132 -0
  71. package/dist/skills/agents-onboarding/templates/langfuse/docker-compose.coolify.yml +189 -0
  72. package/dist/skills/agents-onboarding/templates/langfuse/docker-compose.yml +185 -0
  73. package/dist/skills/agents-operation/SKILL.md +42 -0
  74. package/dist/skills/agents-operation/gotchas.md +61 -0
  75. package/dist/skills/agents-operation/guardrails.md +26 -0
  76. package/dist/skills/agents-operation/references/00-production-safety.md +24 -0
  77. package/dist/skills/agents-operation/references/01-diagnose.md +34 -0
  78. package/dist/skills/agents-operation/references/02-reproduce.md +22 -0
  79. package/dist/skills/agents-operation/references/03-adjust.md +36 -0
  80. package/dist/skills/agents-operation/references/04-validate-and-apply.md +31 -0
  81. package/dist/ui-select.js +279 -0
  82. package/dist/ui.js +167 -0
  83. package/package.json +53 -0
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env bun
2
+ import { randomBytes } from "node:crypto";
3
+ import { chmodSync } from "node:fs";
4
+
5
+ // Generates the secrets + connection URLs the platform-neutral stack (docker-compose.prod.yml) needs,
6
+ // for Tier B/D onboarding (Portainer / EasyPanel / plain VM) where there is no Coolify to mint magic
7
+ // vars. Writes a .env holding the TWO database roles (superuser + NON-superuser runtime), JWT_SECRET,
8
+ // ENCRYPTION_KEY, and the derived MIGRATION_DATABASE_URL / DATABASE_URL / LANGGRAPH_DATABASE_URL /
9
+ // PUBLIC_URL. For these tiers the .env IS the deploy destination for the secrets (Coolify keeps its
10
+ // own store and ignores this script). See docs/deploy.md for the two-role contract.
11
+
12
+ export interface OnboardingEnvOptions {
13
+ publicUrl: string;
14
+ dbHost?: string;
15
+ dbPort?: number;
16
+ dbName?: string;
17
+ pgUser?: string;
18
+ appUser?: string;
19
+ corsOrigin?: string;
20
+ appPort?: string;
21
+ // Bare hostname Caddy serves TLS for (docker-compose.portainer.yml). Defaults to PUBLIC_URL's host.
22
+ caddyDomain?: string;
23
+ // Optional ACME contact for expiry notices + better rate-limit standing. Caddy works without it.
24
+ acmeEmail?: string;
25
+ }
26
+
27
+ export interface OnboardingEnv {
28
+ PUBLIC_URL: string;
29
+ CORS_ORIGIN: string;
30
+ LOG_LEVEL: string;
31
+ APP_PORT: string;
32
+ // NOTE: disabled ONLY for this trusted first-run deploy (the operator drives /setup right after
33
+ // provisioning), removing token friction. The app-wide default in src/config.ts stays `true`.
34
+ SETUP_TOKEN_REQUIRED: string;
35
+ DATABASE_NAME: string;
36
+ POSTGRES_USER: string;
37
+ POSTGRES_PASSWORD: string;
38
+ JWT_SECRET: string;
39
+ ENCRYPTION_KEY: string;
40
+ MIGRATION_DATABASE_URL: string;
41
+ DATABASE_URL: string;
42
+ LANGGRAPH_DATABASE_URL: string;
43
+ // Consumed only by docker-compose.portainer.yml's bundled Caddy; harmless for the BYO-proxy stack.
44
+ CADDY_DOMAIN: string;
45
+ ACME_EMAIL?: string;
46
+ }
47
+
48
+ // URL-safe secret: hex has no characters that need escaping inside a postgres:// URL or a .env value.
49
+ function secret(bytes: number): string {
50
+ return randomBytes(bytes).toString("hex");
51
+ }
52
+
53
+ const ROLE_NAME = /^[A-Za-z0-9_-]+$/;
54
+
55
+ export function buildOnboardingEnv(opts: OnboardingEnvOptions): OnboardingEnv {
56
+ const publicUrl = opts.publicUrl.replace(/\/+$/, "");
57
+ if (!/^https?:\/\//.test(publicUrl)) {
58
+ throw new Error(
59
+ `PUBLIC_URL must start with http:// or https:// (got "${opts.publicUrl}")`,
60
+ );
61
+ }
62
+ const dbHost = opts.dbHost ?? "postgres";
63
+ const dbPort = opts.dbPort ?? 5432;
64
+ const dbName = opts.dbName ?? "secretaria_v4_db";
65
+ const pgUser = opts.pgUser ?? "postgres";
66
+ const appUser = opts.appUser ?? "secv4_app";
67
+ // db-bootstrap.ts interpolates the runtime role name into DDL as a quoted identifier; keep both
68
+ // role names to the safe charset it accepts so a generated .env can never break that out.
69
+ for (const [label, name] of [
70
+ ["postgres superuser", pgUser],
71
+ ["app role", appUser],
72
+ ] as const) {
73
+ if (!ROLE_NAME.test(name)) {
74
+ throw new Error(
75
+ `${label} name must match [A-Za-z0-9_-]+ (got "${name}")`,
76
+ );
77
+ }
78
+ }
79
+
80
+ const pgPassword = secret(24);
81
+ const appPassword = secret(24);
82
+ const authority = (user: string, pass: string) =>
83
+ `postgres://${user}:${pass}@${dbHost}:${dbPort}/${dbName}`;
84
+ // The checkpointer pool MUST be the runtime (non-superuser) role, never the migration URL.
85
+ const databaseUrl = authority(appUser, appPassword);
86
+
87
+ // Caddy serves TLS for the bare host; PUBLIC_URL carries the scheme so derive the host from it.
88
+ const caddyDomain = opts.caddyDomain ?? new URL(publicUrl).hostname;
89
+ const acmeEmail = opts.acmeEmail?.trim();
90
+ if (acmeEmail !== undefined && acmeEmail !== "" && !acmeEmail.includes("@")) {
91
+ throw new Error(`ACME_EMAIL must be an email address (got "${acmeEmail}")`);
92
+ }
93
+
94
+ return {
95
+ PUBLIC_URL: publicUrl,
96
+ CORS_ORIGIN: opts.corsOrigin ?? "*",
97
+ LOG_LEVEL: "info",
98
+ APP_PORT: opts.appPort ?? "3000",
99
+ SETUP_TOKEN_REQUIRED: "false",
100
+ DATABASE_NAME: dbName,
101
+ POSTGRES_USER: pgUser,
102
+ POSTGRES_PASSWORD: pgPassword,
103
+ JWT_SECRET: secret(32),
104
+ ENCRYPTION_KEY: secret(32),
105
+ MIGRATION_DATABASE_URL: authority(pgUser, pgPassword),
106
+ DATABASE_URL: databaseUrl,
107
+ LANGGRAPH_DATABASE_URL: databaseUrl,
108
+ CADDY_DOMAIN: caddyDomain,
109
+ ...(acmeEmail ? { ACME_EMAIL: acmeEmail } : {}),
110
+ };
111
+ }
112
+
113
+ function serializeEnv(env: OnboardingEnv): string {
114
+ const header = [
115
+ "# Generated by scripts/gen-onboarding-env.ts for docker-compose.prod.yml.",
116
+ "# SENSITIVE: holds DB passwords, JWT_SECRET and ENCRYPTION_KEY. Do NOT commit.",
117
+ "# Rotating ENCRYPTION_KEY invalidates all encrypted-at-rest data (vault, Chatwoot tokens).",
118
+ "",
119
+ "",
120
+ ].join("\n");
121
+ return `${header}${Object.entries(env)
122
+ .map(([k, v]) => `${k}=${v}`)
123
+ .join("\n")}\n`;
124
+ }
125
+
126
+ function flag(name: string): string | undefined {
127
+ const eq = `--${name}=`;
128
+ const hit = process.argv.find((a) => a.startsWith(eq));
129
+ if (hit) return hit.slice(eq.length);
130
+ const idx = process.argv.indexOf(`--${name}`);
131
+ const next = process.argv[idx + 1];
132
+ if (idx !== -1 && next && !next.startsWith("--")) return next;
133
+ return undefined;
134
+ }
135
+
136
+ async function main() {
137
+ const publicUrl = flag("public-url") ?? process.env.PUBLIC_URL;
138
+ if (!publicUrl) {
139
+ console.error(
140
+ "usage: bun scripts/gen-onboarding-env.ts --public-url https://agentes.example.com [--out .env] [--force]\n" +
141
+ " [--db-host postgres] [--db-port 5432] [--db-name secretaria_v4_db] [--pg-user postgres] [--app-user secv4_app]\n" +
142
+ " [--caddy-domain agentes.example.com] [--acme-email you@example.com] # for docker-compose.portainer.yml",
143
+ );
144
+ process.exit(1);
145
+ }
146
+ const out = flag("out") ?? ".env";
147
+ const force = process.argv.includes("--force");
148
+ if (!force && (await Bun.file(out).exists())) {
149
+ console.error(
150
+ `refusing to overwrite existing ${out} (would rotate secrets and break a running deploy). Pass --force to replace.`,
151
+ );
152
+ process.exit(1);
153
+ }
154
+
155
+ const env = buildOnboardingEnv({
156
+ publicUrl,
157
+ dbHost: flag("db-host"),
158
+ dbPort: flag("db-port") ? Number(flag("db-port")) : undefined,
159
+ dbName: flag("db-name"),
160
+ pgUser: flag("pg-user"),
161
+ appUser: flag("app-user"),
162
+ corsOrigin: flag("cors-origin"),
163
+ appPort: flag("app-port"),
164
+ caddyDomain: flag("caddy-domain"),
165
+ acmeEmail: flag("acme-email"),
166
+ });
167
+
168
+ await Bun.write(out, serializeEnv(env));
169
+ try {
170
+ chmodSync(out, 0o600);
171
+ } catch {
172
+ // best-effort (e.g. a non-POSIX filesystem); the file is written regardless.
173
+ }
174
+
175
+ // Masked summary — never print the secret values (they would land in logs).
176
+ const appRole = new URL(env.DATABASE_URL).username;
177
+ console.log(
178
+ `wrote ${out} (chmod 600): PUBLIC_URL=${env.PUBLIC_URL}, db=${env.DATABASE_NAME}, superuser=${env.POSTGRES_USER}, runtime role=${appRole} (non-superuser).`,
179
+ );
180
+ console.log(
181
+ "Secrets (POSTGRES_PASSWORD, app-role password, JWT_SECRET, ENCRYPTION_KEY) were written to the file, not printed.",
182
+ );
183
+ }
184
+
185
+ if (import.meta.main) {
186
+ await main();
187
+ }
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python3
2
+ # Harbor (private registry) login on the VPS for the fazer.ai agents onboarding. Runs `docker login
3
+ # --password-stdin` over SSH so the robot secret never lands in argv / ps / shell history, and the robot
4
+ # username (which contains a "$", e.g. robot$project, a shell-expansion footgun, same family as the
5
+ # Coolify token's "|") is decoded in-shell from base64 and passed quoted. The credential comes from the
6
+ # hub MCP (create_registry_credential / generate_install_script); write the secret to a 0600 file and
7
+ # pass --secret-file. Python 3 stdlib only. SSH runs via Bash with dangerouslyDisableSandbox:true.
8
+ import argparse
9
+ import base64
10
+ import json
11
+ import os
12
+ import re
13
+ import shlex
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ # Git Bash/MSYS on Windows reports paths as "/c/Users/..." (or "/mnt/c/..." under WSL), but native Python on
19
+ # Windows cannot open those: it needs "C:\Users\...". Match a leading drive segment so we can retranslate.
20
+ MSYS_DRIVE_RE = re.compile(r"^/(?:mnt/)?([A-Za-z])/(.*)$")
21
+
22
+
23
+ def out(obj, code=0):
24
+ print(json.dumps(obj))
25
+ sys.exit(code)
26
+
27
+
28
+ def fail(msg, **extra):
29
+ out({"ok": False, "error": msg, **extra}, code=1)
30
+
31
+
32
+ def msys_to_native(path):
33
+ # Translate a Git Bash/MSYS/WSL path ("/c/Users/me/x", "/mnt/c/Users/me/x") to the native Windows form
34
+ # ("C:\Users\me\x") so open() finds it. Returns None when the path is not drive-prefixed (nothing to
35
+ # translate). POSIX paths and already-native Windows paths ("C:\...", "C:/...") are left to open() as-is.
36
+ m = MSYS_DRIVE_RE.match(path)
37
+ if not m:
38
+ return None
39
+ drive, rest = m.group(1), m.group(2)
40
+ return f"{drive.upper()}:\\" + rest.replace("/", "\\")
41
+
42
+
43
+ def read_text_file(path, what):
44
+ # Read a UTF-8 text file, retrying a Git Bash/MSYS path ("/c/Users/me/x") as its native Windows form when
45
+ # the first open fails (same footgun as remote.py's --script-file). `what` names the flag for the error.
46
+ try:
47
+ return Path(path).read_text(encoding="utf-8")
48
+ except OSError as exc:
49
+ native = msys_to_native(path)
50
+ if native is None:
51
+ fail(f"cannot read {what}: {exc}")
52
+ try:
53
+ return Path(native).read_text(encoding="utf-8")
54
+ except OSError as exc2:
55
+ fail(f"cannot read {what} (tried {native!r}): {exc2}")
56
+
57
+
58
+ def split_ssh_opts(opts, _nt=None):
59
+ # POSIX shlex eats backslashes, mangling a Windows key path ("-i C:\Users\me\.ssh\key" ->
60
+ # "C:Usersme.sshkey"). On Windows, tokenize without escape processing and strip our own quotes so the
61
+ # backslashes survive. _nt is injectable for tests.
62
+ nt = (os.name == "nt") if _nt is None else _nt
63
+ if not opts:
64
+ return []
65
+ if nt:
66
+ toks = shlex.split(opts, posix=False)
67
+ return [t[1:-1] if len(t) >= 2 and t[0] == t[-1] and t[0] in "\"'" else t for t in toks]
68
+ return shlex.split(opts)
69
+
70
+
71
+ def cmd_login(args):
72
+ secret = read_text_file(args.secret_file, "--secret-file").strip()
73
+ if not secret:
74
+ fail("--secret-file is empty")
75
+ user_b64 = base64.b64encode(args.username.encode("utf-8")).decode("ascii")
76
+ secret_b64 = base64.b64encode(secret.encode("utf-8")).decode("ascii")
77
+ # Decode the username in-shell (protects the "$") and feed the secret via --password-stdin.
78
+ remote = (
79
+ f"U=$(echo '{user_b64}' | base64 -d); "
80
+ f"echo '{secret_b64}' | base64 -d | docker login -u \"$U\" --password-stdin {args.registry}"
81
+ )
82
+ argv = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", *split_ssh_opts(args.ssh_opts), args.ssh, remote]
83
+ try:
84
+ proc = subprocess.run(argv, capture_output=True, text=True, timeout=args.timeout)
85
+ except FileNotFoundError:
86
+ fail("ssh not found on PATH")
87
+ except subprocess.TimeoutExpired:
88
+ fail(f"ssh timed out after {args.timeout}s")
89
+ combined = (proc.stdout or "") + (proc.stderr or "")
90
+ if proc.returncode != 0 or "Login Succeeded" not in combined:
91
+ fail("docker login failed", exit_code=proc.returncode, stderr=(proc.stderr or "")[-400:])
92
+ out({"ok": True, "registry": args.registry, "result": "Login Succeeded"})
93
+
94
+
95
+ def build_parser():
96
+ parser = argparse.ArgumentParser(
97
+ prog="harbor-login.py",
98
+ description="docker login to a private registry on the VPS; secret via stdin, robot '$' protected.",
99
+ )
100
+ sub = parser.add_subparsers(dest="cmd", required=True)
101
+ login = sub.add_parser("login", help="docker login over SSH (secret via --password-stdin)")
102
+ login.add_argument("--ssh", required=True, metavar="USER@HOST")
103
+ login.add_argument("--username", required=True, help="robot account, e.g. 'robot$project+name'")
104
+ login.add_argument("--secret-file", required=True, help="file holding the robot secret (chmod 600)")
105
+ login.add_argument("--registry", default="harbor.fazer.ai")
106
+ login.add_argument("--ssh-opts", default="", help="extra ssh options, e.g. '-i ~/.ssh/key -p 2222'")
107
+ login.add_argument("--timeout", type=int, default=60)
108
+ login.set_defaults(fn=cmd_login)
109
+ return parser
110
+
111
+
112
+ def main():
113
+ args = build_parser().parse_args()
114
+ args.fn(args)
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python3
2
+ # Langfuse ingestion smoke test for the fazer.ai agents onboarding. POSTs a tiny batch to
3
+ # /api/public/ingestion with HTTP Basic auth and asserts 207/200, NOT 500. This is the load-bearing
4
+ # check: a naive "test connection" hits /api/public/projects, which reads only Postgres and returns 200
5
+ # even when blob storage (MinIO/S3) is missing, masking a broken ingestion that silently drops every
6
+ # trace. The Langfuse keys come from --keys-file (JSON {publicKey, secretKey}); the secret key never
7
+ # touches argv. Python 3 stdlib only.
8
+ import argparse
9
+ import base64
10
+ import json
11
+ import re
12
+ import sys
13
+ import urllib.error
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ # Git Bash/MSYS on Windows reports paths as "/c/Users/..." (or "/mnt/c/..." under WSL), but native Python on
18
+ # Windows cannot open those: it needs "C:\Users\...". Match a leading drive segment so we can retranslate.
19
+ MSYS_DRIVE_RE = re.compile(r"^/(?:mnt/)?([A-Za-z])/(.*)$")
20
+
21
+ BATCH = {
22
+ "batch": [
23
+ {
24
+ "id": "verify-1",
25
+ "type": "trace-create",
26
+ "timestamp": "2026-01-01T00:00:00.000Z",
27
+ "body": {"id": "verify-1", "name": "onboarding-verify"},
28
+ }
29
+ ]
30
+ }
31
+
32
+
33
+ def out(obj, code=0):
34
+ print(json.dumps(obj))
35
+ sys.exit(code)
36
+
37
+
38
+ def fail(msg, **extra):
39
+ out({"ok": False, "error": msg, **extra}, code=1)
40
+
41
+
42
+ def msys_to_native(path):
43
+ # Translate a Git Bash/MSYS/WSL path ("/c/Users/me/x", "/mnt/c/Users/me/x") to the native Windows form
44
+ # ("C:\Users\me\x") so open() finds it. Returns None when the path is not drive-prefixed (nothing to
45
+ # translate). POSIX paths and already-native Windows paths ("C:\...", "C:/...") are left to open() as-is.
46
+ m = MSYS_DRIVE_RE.match(path)
47
+ if not m:
48
+ return None
49
+ drive, rest = m.group(1), m.group(2)
50
+ return f"{drive.upper()}:\\" + rest.replace("/", "\\")
51
+
52
+
53
+ def read_text_file(path, what):
54
+ # Read a UTF-8 text file, retrying a Git Bash/MSYS path ("/c/Users/me/x") as its native Windows form when
55
+ # the first open fails (same footgun as remote.py's --script-file). `what` names the flag for the error.
56
+ try:
57
+ return Path(path).read_text(encoding="utf-8")
58
+ except OSError as exc:
59
+ native = msys_to_native(path)
60
+ if native is None:
61
+ fail(f"cannot read {what}: {exc}")
62
+ try:
63
+ return Path(native).read_text(encoding="utf-8")
64
+ except OSError as exc2:
65
+ fail(f"cannot read {what} (tried {native!r}): {exc2}")
66
+
67
+
68
+ def cmd_ingestion(args):
69
+ try:
70
+ keys = json.loads(read_text_file(args.keys_file, "--keys-file"))
71
+ except ValueError as exc:
72
+ fail(f"--keys-file is not valid JSON (expects {{publicKey, secretKey}}): {exc}")
73
+ public_key, secret_key = keys.get("publicKey"), keys.get("secretKey")
74
+ if not public_key or not secret_key:
75
+ fail("--keys-file must hold both publicKey and secretKey")
76
+ url = args.base_url.rstrip("/") + "/api/public/ingestion"
77
+ auth = base64.b64encode(f"{public_key}:{secret_key}".encode("utf-8")).decode("ascii")
78
+ req = urllib.request.Request(
79
+ url,
80
+ data=json.dumps(BATCH).encode("utf-8"),
81
+ method="POST",
82
+ headers={"Authorization": "Basic " + auth, "Content-Type": "application/json"},
83
+ )
84
+ try:
85
+ with urllib.request.urlopen(req, timeout=args.timeout) as resp:
86
+ status, raw = resp.status, resp.read().decode("utf-8", "replace")
87
+ except urllib.error.HTTPError as exc:
88
+ status, raw = exc.code, exc.read().decode("utf-8", "replace")
89
+ except urllib.error.URLError as exc:
90
+ fail(f"request failed: {exc.reason}", url=url)
91
+ ok = status in (200, 207)
92
+ result = {"ok": ok, "status": status, "expected": "207/200", "body": raw[:300]}
93
+ if not ok:
94
+ result["hint"] = "500 usually means missing MinIO/S3 blob storage (LANGFUSE_S3_* empty); traces are dropped"
95
+ out(result, code=0 if ok else 1)
96
+
97
+
98
+ def build_parser():
99
+ parser = argparse.ArgumentParser(
100
+ prog="langfuse-verify.py",
101
+ description="Verify Langfuse ingestion actually works (207/200, not the masked 500).",
102
+ )
103
+ sub = parser.add_subparsers(dest="cmd", required=True)
104
+ ing = sub.add_parser("ingestion", help="POST a tiny batch to /api/public/ingestion and assert 207/200")
105
+ ing.add_argument("--base-url", required=True, metavar="URL", help="e.g. https://langfuse.example.com:3000")
106
+ ing.add_argument("--keys-file", required=True, help="JSON file with {publicKey, secretKey} (chmod 600)")
107
+ ing.add_argument("--timeout", type=int, default=30)
108
+ ing.set_defaults(fn=cmd_ingestion)
109
+ return parser
110
+
111
+
112
+ def main():
113
+ args = build_parser().parse_args()
114
+ args.fn(args)
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env python3
2
+ # Portainer brownfield discovery (read-only): inventory the existing Portainer + its containers,
3
+ # fingerprint each by image, and decide per-service (reuse healthy / install missing / flag incompatible)
4
+ # for the fazer.ai agents onboarding. Mirror of the Coolify brownfield-probe.sh, but Portainer-API-native.
5
+ # Env: PORTAINER_API_KEY, PORTAINER_ENDPOINT_ID, optional PORTAINER_URL (default https://localhost:9443).
6
+ import json, ssl, os, urllib.request
7
+
8
+ B = os.environ.get("PORTAINER_URL", "https://localhost:9443")
9
+ API = os.environ["PORTAINER_API_KEY"]
10
+ EP = os.environ.get("PORTAINER_ENDPOINT_ID", "1")
11
+ ctx = ssl.create_default_context()
12
+ ctx.check_hostname = False
13
+ ctx.verify_mode = ssl.CERT_NONE
14
+
15
+
16
+ def get(path):
17
+ req = urllib.request.Request(B + path, headers={"X-API-Key": API})
18
+ return json.loads(urllib.request.urlopen(req, context=ctx, timeout=30).read())
19
+
20
+
21
+ # image substring -> logical service name (order matters: most specific first)
22
+ FINGERPRINTS = [
23
+ ("agents", "agents"),
24
+ ("chatwoot-pro", "chatwoot-pro"),
25
+ ("chatwoot", "chatwoot-oss"),
26
+ ("langfuse", "langfuse"),
27
+ ("baileys", "baileys"),
28
+ ("pgvector", "postgres-pgvector"),
29
+ ("postgres", "postgres"),
30
+ ("clickhouse", "clickhouse"),
31
+ ("redis", "redis"),
32
+ ("minio", "minio"),
33
+ ("caddy", "proxy-caddy"),
34
+ ("traefik", "proxy-traefik"),
35
+ ("nginx", "proxy-nginx"),
36
+ ("haproxy", "proxy-haproxy"),
37
+ ("portainer", "portainer"),
38
+ ]
39
+
40
+
41
+ def fingerprint(image):
42
+ il = image.lower()
43
+ for key, name in FINGERPRINTS:
44
+ if key in il:
45
+ return name
46
+ return "unknown:" + image.split("/")[-1]
47
+
48
+
49
+ def health_of(state, status):
50
+ if "healthy" in status:
51
+ return "healthy"
52
+ if "health: starting" in status:
53
+ return "starting"
54
+ return state
55
+
56
+
57
+ stacks = get("/api/stacks")
58
+ conts = get("/api/endpoints/%s/docker/containers/json?all=1" % EP)
59
+
60
+ print("=== Portainer-managed stacks ===")
61
+ for s in stacks:
62
+ print(" #%s %-16s status=%s type=%s" % (s["Id"], s["Name"], s.get("Status"), s.get("Type")))
63
+
64
+ print("\n=== Containers (image fingerprint) ===")
65
+ inv = {}
66
+ for c in conts:
67
+ name = fingerprint(c["Image"])
68
+ proj = c.get("Labels", {}).get("com.docker.compose.project", "-")
69
+ h = health_of(c["State"], c["Status"])
70
+ inv.setdefault(name, []).append({"proj": proj, "health": h, "image": c["Image"]})
71
+ print(" %-18s proj=%-18s %s" % (name, proj, c["Status"]))
72
+
73
+ print("\n=== Ingress: who publishes 80/443/9443 ===")
74
+ ingress = []
75
+ for c in conts:
76
+ pubs = sorted({p.get("PublicPort") for p in c.get("Ports", []) if p.get("PublicPort") in (80, 443, 9443)})
77
+ if pubs:
78
+ nm = c["Names"][0].lstrip("/")
79
+ ingress.append((nm, pubs, fingerprint(c["Image"])))
80
+ print(" %-34s -> %s (%s)" % (nm, pubs, fingerprint(c["Image"])))
81
+ proxy_on_443 = any(80 in p or 443 in p for _, p, _ in ingress)
82
+ print(" => 80/443 %s" % ("OCCUPIED (an ingress exists; agents's bundled-Caddy stack would conflict -> reuse it or use docker-compose.prod.yml BYO-proxy)" if proxy_on_443 else "FREE (bundled-Caddy stack can bind)"))
83
+
84
+
85
+ def decide(service):
86
+ hits = inv.get(service, [])
87
+ if not hits:
88
+ return "ABSENT -> install"
89
+ healthy = [h for h in hits if h["health"] == "healthy"]
90
+ if healthy:
91
+ return "PRESENT+healthy -> REUSE (proj=%s)" % healthy[0]["proj"]
92
+ return "PRESENT+unhealthy -> flag/investigate (%s)" % [h["health"] for h in hits]
93
+
94
+
95
+ print("\n=== Per-service decision for the agents onboarding ===")
96
+ # Chatwoot has TWO valid variants: chatwoot-pro (Harbor image, hub subscription) and chatwoot OSS
97
+ # (public image). Both satisfy the agents integration (Agent Bot API). Pro adds Baileys WhatsApp + Kanban.
98
+ # So OSS is REUSABLE, not incompatible; an absent Chatwoot installs pro-if-subscribed else oss.
99
+ def decide_chatwoot():
100
+ pro = [h for h in inv.get("chatwoot-pro", []) if h["health"] == "healthy"]
101
+ oss = [h for h in inv.get("chatwoot-oss", []) if h["health"] == "healthy"]
102
+ if pro:
103
+ return "PRESENT(pro)+healthy -> REUSE (Baileys/Kanban available)"
104
+ if oss:
105
+ return "PRESENT(oss)+healthy -> REUSE (OSS; no Baileys/Kanban, use official WA channels)"
106
+ if inv.get("chatwoot-pro") or inv.get("chatwoot-oss"):
107
+ return "PRESENT+unhealthy -> flag/investigate"
108
+ return "ABSENT -> install (pro if hub subscription, else oss)"
109
+ TARGETS = [
110
+ ("agents", decide("agents"), "required (the app)"),
111
+ ("chatwoot", decide_chatwoot(), "required (integration); pro=Harbor/hub-sub, oss=public"),
112
+ ("langfuse", decide("langfuse"), "optional (tracing)"),
113
+ ]
114
+ for name, dec, note in TARGETS:
115
+ print(" %-16s %-46s | %s" % (name, dec, note))