From 2acf88af1c5978034e28602c47f9c5b84cbe6a97 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 13:53:53 -0400 Subject: [PATCH 1/5] fix(boot-migrations): bound the /health migrations status call with a 5 s timeout (D-0887) --- free/audit-log/internal/boot_migrations.go | 5 +- free/auth-enterprise/cmd/boot_migrations.go | 5 +- free/byok/cmd/boot_migrations.go | 5 +- free/cdc/cmd/boot_migrations.go | 5 +- .../internal/boot_migrations.go | 6 ++- free/crdt/go/cmd/boot_migrations.go | 6 ++- free/email/cmd/boot_migrations.go | 6 ++- free/entitlements/internal/boot_migrations.go | 5 +- free/event-bus/cmd/boot_migrations.go | 6 ++- .../feature-flags/internal/boot_migrations.go | 5 +- free/functions-v8/cmd/boot_migrations.go | 6 ++- free/github/internal/boot_migrations.go | 5 +- free/idme/internal/boot_migrations.go | 5 +- free/invitations/internal/boot_migrations.go | 5 +- free/jobs/cmd/boot_migrations.go | 5 +- free/link-preview/internal/boot_migrations.go | 5 +- free/mdns/internal/boot_migrations.go | 5 +- free/meetings/internal/boot_migrations.go | 6 ++- .../notifications/internal/boot_migrations.go | 5 +- free/notify/internal/boot_migrations.go | 5 +- free/paypal/internal/boot_migrations.go | 5 +- .../plugin-clawde/internal/boot_migrations.go | 5 +- .../internal/boot_migrations.go | 5 +- .../internal/boot_migrations.go | 5 +- free/podcast/internal/boot_migrations.go | 5 +- free/post/internal/boot_migrations.go | 5 +- free/push/internal/boot_migrations.go | 5 +- free/retro-gaming/internal/boot_migrations.go | 6 ++- free/search/internal/boot_migrations.go | 5 +- free/sms/cmd/boot_migrations.go | 6 ++- free/storage/internal/boot_migrations.go | 5 +- free/stripe/internal/boot_migrations.go | 5 +- .../internal/boot_migrations.go | 6 ++- free/tokens/internal/boot_migrations.go | 5 +- .../internal/boot_migrations.go | 6 ++- free/vpn/internal/boot_migrations.go | 6 ++- free/webhooks/internal/boot_migrations.go | 5 +- .../codemods/bootmig/health_timeout_test.go | 49 +++++++++++++++++++ scripts/codemods/bootmig/run.go | 6 ++- 39 files changed, 213 insertions(+), 38 deletions(-) create mode 100644 scripts/codemods/bootmig/health_timeout_test.go diff --git a/free/audit-log/internal/boot_migrations.go b/free/audit-log/internal/boot_migrations.go index 2d961e8dc..293c8b8e8 100644 --- a/free/audit-log/internal/boot_migrations.go +++ b/free/audit-log/internal/boot_migrations.go @@ -35,7 +35,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/auth-enterprise/cmd/boot_migrations.go b/free/auth-enterprise/cmd/boot_migrations.go index e5cd00ab4..6e20a8a09 100644 --- a/free/auth-enterprise/cmd/boot_migrations.go +++ b/free/auth-enterprise/cmd/boot_migrations.go @@ -35,7 +35,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/byok/cmd/boot_migrations.go b/free/byok/cmd/boot_migrations.go index 8510374a9..8cc404a22 100644 --- a/free/byok/cmd/boot_migrations.go +++ b/free/byok/cmd/boot_migrations.go @@ -35,7 +35,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/cdc/cmd/boot_migrations.go b/free/cdc/cmd/boot_migrations.go index 833558155..ff489cc9c 100644 --- a/free/cdc/cmd/boot_migrations.go +++ b/free/cdc/cmd/boot_migrations.go @@ -41,7 +41,10 @@ func bootMigrationsHealth(w http.ResponseWriter) map[string]int { if bootPool == nil { return nil } - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("cdc: migrations: status: %v", err) w.Header().Set("Content-Type", "application/json") diff --git a/free/content-safety/internal/boot_migrations.go b/free/content-safety/internal/boot_migrations.go index b401240ac..867ac8214 100644 --- a/free/content-safety/internal/boot_migrations.go +++ b/free/content-safety/internal/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -33,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/crdt/go/cmd/boot_migrations.go b/free/crdt/go/cmd/boot_migrations.go index 1261dd858..64d841d0c 100644 --- a/free/crdt/go/cmd/boot_migrations.go +++ b/free/crdt/go/cmd/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -33,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/email/cmd/boot_migrations.go b/free/email/cmd/boot_migrations.go index ef8f047fe..10830055d 100644 --- a/free/email/cmd/boot_migrations.go +++ b/free/email/cmd/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -33,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/entitlements/internal/boot_migrations.go b/free/entitlements/internal/boot_migrations.go index 41eaa03ff..b835d43a2 100644 --- a/free/entitlements/internal/boot_migrations.go +++ b/free/entitlements/internal/boot_migrations.go @@ -38,7 +38,10 @@ func bootMigrationsHealth(w http.ResponseWriter) map[string]int { if bootPool == nil { // handlers built without mustBootMigrations (unit tests); the server always sets the pool before serving return nil } - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.Header().Set("Content-Type", "application/json") diff --git a/free/event-bus/cmd/boot_migrations.go b/free/event-bus/cmd/boot_migrations.go index 45d57e9da..11693a2b3 100644 --- a/free/event-bus/cmd/boot_migrations.go +++ b/free/event-bus/cmd/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -33,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/feature-flags/internal/boot_migrations.go b/free/feature-flags/internal/boot_migrations.go index 39264ace3..7add685f5 100644 --- a/free/feature-flags/internal/boot_migrations.go +++ b/free/feature-flags/internal/boot_migrations.go @@ -88,7 +88,10 @@ func reportParkedTables(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/functions-v8/cmd/boot_migrations.go b/free/functions-v8/cmd/boot_migrations.go index 15c604e14..8c9040bfe 100644 --- a/free/functions-v8/cmd/boot_migrations.go +++ b/free/functions-v8/cmd/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -33,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/github/internal/boot_migrations.go b/free/github/internal/boot_migrations.go index 77905132b..9226c3d2d 100644 --- a/free/github/internal/boot_migrations.go +++ b/free/github/internal/boot_migrations.go @@ -35,7 +35,10 @@ func MustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/idme/internal/boot_migrations.go b/free/idme/internal/boot_migrations.go index b9dafeeed..3e6e4397d 100644 --- a/free/idme/internal/boot_migrations.go +++ b/free/idme/internal/boot_migrations.go @@ -38,7 +38,10 @@ func BootMigrationsHealth(w http.ResponseWriter) map[string]int { if bootPool == nil { // unit tests build the router without a database; boot always sets the pool return map[string]int{"applied": 0, "expected": 0} } - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/invitations/internal/boot_migrations.go b/free/invitations/internal/boot_migrations.go index 1c0fd25b1..0cd257f51 100644 --- a/free/invitations/internal/boot_migrations.go +++ b/free/invitations/internal/boot_migrations.go @@ -35,7 +35,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/jobs/cmd/boot_migrations.go b/free/jobs/cmd/boot_migrations.go index feb9b143e..a2342c75d 100644 --- a/free/jobs/cmd/boot_migrations.go +++ b/free/jobs/cmd/boot_migrations.go @@ -35,7 +35,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/link-preview/internal/boot_migrations.go b/free/link-preview/internal/boot_migrations.go index b4a3ee1af..266f354c4 100644 --- a/free/link-preview/internal/boot_migrations.go +++ b/free/link-preview/internal/boot_migrations.go @@ -35,7 +35,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/mdns/internal/boot_migrations.go b/free/mdns/internal/boot_migrations.go index d4564f07a..6fbeacdd5 100644 --- a/free/mdns/internal/boot_migrations.go +++ b/free/mdns/internal/boot_migrations.go @@ -35,7 +35,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/meetings/internal/boot_migrations.go b/free/meetings/internal/boot_migrations.go index 95d1502bd..fe324b8d8 100644 --- a/free/meetings/internal/boot_migrations.go +++ b/free/meetings/internal/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -36,7 +37,10 @@ func bootMigrationsHealth(w http.ResponseWriter) map[string]int { if bootPool == nil { // no boot ran (unit tests build a bare Handlers); a served process always has the pool return map[string]int{"applied": 0, "expected": 0} } - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/notifications/internal/boot_migrations.go b/free/notifications/internal/boot_migrations.go index 9d41d1781..ec7676f71 100644 --- a/free/notifications/internal/boot_migrations.go +++ b/free/notifications/internal/boot_migrations.go @@ -35,7 +35,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/notify/internal/boot_migrations.go b/free/notify/internal/boot_migrations.go index c6c364509..f6cdfea01 100644 --- a/free/notify/internal/boot_migrations.go +++ b/free/notify/internal/boot_migrations.go @@ -35,7 +35,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/paypal/internal/boot_migrations.go b/free/paypal/internal/boot_migrations.go index 4eff6f76f..970130989 100644 --- a/free/paypal/internal/boot_migrations.go +++ b/free/paypal/internal/boot_migrations.go @@ -40,7 +40,10 @@ func bootMigrationsHealth(w http.ResponseWriter) map[string]int { if bootPool == nil { err = errors.New("no database pool (boot migrations have not run)") } else { - st, err = migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err = migrate.Status(ctx, bootPool, bootOptions()) } if err != nil { log.Printf("migrations: status: %v", err) diff --git a/free/plugin-clawde/internal/boot_migrations.go b/free/plugin-clawde/internal/boot_migrations.go index 993a98b5f..b397f812f 100644 --- a/free/plugin-clawde/internal/boot_migrations.go +++ b/free/plugin-clawde/internal/boot_migrations.go @@ -46,7 +46,10 @@ func MustBootMigrations(ctx context.Context, dbURL string) *pgxpool.Pool { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/plugin-llm-gateway/internal/boot_migrations.go b/free/plugin-llm-gateway/internal/boot_migrations.go index e331ee7e7..c101e4488 100644 --- a/free/plugin-llm-gateway/internal/boot_migrations.go +++ b/free/plugin-llm-gateway/internal/boot_migrations.go @@ -46,7 +46,10 @@ func MustBootMigrations(ctx context.Context, dbURL string) *pgxpool.Pool { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/plugin-retrieval/internal/boot_migrations.go b/free/plugin-retrieval/internal/boot_migrations.go index 51eaf0e8f..fd2bb97cd 100644 --- a/free/plugin-retrieval/internal/boot_migrations.go +++ b/free/plugin-retrieval/internal/boot_migrations.go @@ -46,7 +46,10 @@ func MustBootMigrations(ctx context.Context, dbURL string) *pgxpool.Pool { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/podcast/internal/boot_migrations.go b/free/podcast/internal/boot_migrations.go index 0987d18ee..3558e8be7 100644 --- a/free/podcast/internal/boot_migrations.go +++ b/free/podcast/internal/boot_migrations.go @@ -40,7 +40,10 @@ func bootMigrationsHealth(w http.ResponseWriter) map[string]int { if bootPool == nil { err = errors.New("no database pool (boot migrations have not run)") } else { - st, err = migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err = migrate.Status(ctx, bootPool, bootOptions()) } if err != nil { log.Printf("migrations: status: %v", err) diff --git a/free/post/internal/boot_migrations.go b/free/post/internal/boot_migrations.go index 83458d99d..74b6a508c 100644 --- a/free/post/internal/boot_migrations.go +++ b/free/post/internal/boot_migrations.go @@ -40,7 +40,10 @@ func bootMigrationsHealth(w http.ResponseWriter) map[string]int { if bootPool == nil { err = errors.New("no database pool (boot migrations have not run)") } else { - st, err = migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err = migrate.Status(ctx, bootPool, bootOptions()) } if err != nil { log.Printf("migrations: status: %v", err) diff --git a/free/push/internal/boot_migrations.go b/free/push/internal/boot_migrations.go index dcd563844..7f89a1032 100644 --- a/free/push/internal/boot_migrations.go +++ b/free/push/internal/boot_migrations.go @@ -40,7 +40,10 @@ func bootMigrationsHealth(w http.ResponseWriter) map[string]int { if bootPool == nil { err = errors.New("no database pool (boot migrations have not run)") } else { - st, err = migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err = migrate.Status(ctx, bootPool, bootOptions()) } if err != nil { log.Printf("migrations: status: %v", err) diff --git a/free/retro-gaming/internal/boot_migrations.go b/free/retro-gaming/internal/boot_migrations.go index 6acad843d..d59668699 100644 --- a/free/retro-gaming/internal/boot_migrations.go +++ b/free/retro-gaming/internal/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -33,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/search/internal/boot_migrations.go b/free/search/internal/boot_migrations.go index 8e0d470e7..319d6f933 100644 --- a/free/search/internal/boot_migrations.go +++ b/free/search/internal/boot_migrations.go @@ -64,7 +64,10 @@ func reportLegacyTables(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/sms/cmd/boot_migrations.go b/free/sms/cmd/boot_migrations.go index a9b2c7704..5038e4693 100644 --- a/free/sms/cmd/boot_migrations.go +++ b/free/sms/cmd/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -33,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/storage/internal/boot_migrations.go b/free/storage/internal/boot_migrations.go index bf2a26313..aabefc9ab 100644 --- a/free/storage/internal/boot_migrations.go +++ b/free/storage/internal/boot_migrations.go @@ -35,7 +35,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/stripe/internal/boot_migrations.go b/free/stripe/internal/boot_migrations.go index e2d453f34..cfb0fff53 100644 --- a/free/stripe/internal/boot_migrations.go +++ b/free/stripe/internal/boot_migrations.go @@ -36,7 +36,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/subtitle-manager/internal/boot_migrations.go b/free/subtitle-manager/internal/boot_migrations.go index aacf75c60..4ea6f8c7b 100644 --- a/free/subtitle-manager/internal/boot_migrations.go +++ b/free/subtitle-manager/internal/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -33,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/tokens/internal/boot_migrations.go b/free/tokens/internal/boot_migrations.go index b1325b5f0..dcae1827b 100644 --- a/free/tokens/internal/boot_migrations.go +++ b/free/tokens/internal/boot_migrations.go @@ -35,7 +35,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/torrent-manager/internal/boot_migrations.go b/free/torrent-manager/internal/boot_migrations.go index b49861e9a..d6e6770da 100644 --- a/free/torrent-manager/internal/boot_migrations.go +++ b/free/torrent-manager/internal/boot_migrations.go @@ -7,6 +7,7 @@ import ( "encoding/json" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -39,7 +40,10 @@ func bootMigrationsHealth(w http.ResponseWriter) map[string]int { w.WriteHeader(http.StatusServiceUnavailable) return map[string]int{"applied": -1, "expected": -1} } - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/vpn/internal/boot_migrations.go b/free/vpn/internal/boot_migrations.go index b4c1a5ccf..38114dee4 100644 --- a/free/vpn/internal/boot_migrations.go +++ b/free/vpn/internal/boot_migrations.go @@ -7,6 +7,7 @@ import ( "encoding/json" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -39,7 +40,10 @@ func bootMigrationsHealth(w http.ResponseWriter) map[string]int { w.WriteHeader(http.StatusServiceUnavailable) return map[string]int{"applied": -1, "expected": -1} } - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/webhooks/internal/boot_migrations.go b/free/webhooks/internal/boot_migrations.go index a5b5f9cb0..39c81f1a3 100644 --- a/free/webhooks/internal/boot_migrations.go +++ b/free/webhooks/internal/boot_migrations.go @@ -63,7 +63,10 @@ func reportLegacyTables(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/scripts/codemods/bootmig/health_timeout_test.go b/scripts/codemods/bootmig/health_timeout_test.go new file mode 100644 index 000000000..63624b26a --- /dev/null +++ b/scripts/codemods/bootmig/health_timeout_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// statusBackground matches a migrate.Status call on a context with no deadline. +var statusBackground = regexp.MustCompile(`migrate\.Status\(\s*context\.(Background|TODO)\(\)`) + +// A hung database must fail /health, not hang it (D-0887): the template bounds Status with a timeout. +func TestHelperTemplateBoundsStatus(t *testing.T) { + if statusBackground.MatchString(helperTmpl) { + t.Fatal("helperTmpl calls migrate.Status with an unbounded context") + } + for _, want := range []string{`context.WithTimeout(context.Background(), 5*time.Second)`, "defer cancel()", `"time"`} { + if !strings.Contains(helperTmpl, want) { + t.Errorf("helperTmpl missing %q", want) + } + } +} + +// The generated copies in the plugins carry the same fix. +func TestGeneratedHelpersBoundStatus(t *testing.T) { + root := filepath.Join("..", "..", "..", "free") + var files []string + for _, pat := range []string{"*/internal/boot_migrations.go", "*/cmd/boot_migrations.go"} { + m, err := filepath.Glob(filepath.Join(root, pat)) + if err != nil { + t.Fatal(err) + } + files = append(files, m...) + } + if len(files) == 0 { + t.Skip("no plugin tree next to the codemod") + } + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + if statusBackground.Match(b) { + t.Errorf("%s: migrate.Status on an unbounded context", f) + } + } +} diff --git a/scripts/codemods/bootmig/run.go b/scripts/codemods/bootmig/run.go index 72003308a..b0184f971 100644 --- a/scripts/codemods/bootmig/run.go +++ b/scripts/codemods/bootmig/run.go @@ -18,6 +18,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -45,7 +46,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %%v", err) w.WriteHeader(http.StatusServiceUnavailable) From de7eac658a1de27a2a84667e47033a1bec5c54ff Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 13:53:53 -0400 Subject: [PATCH 2/5] fix(traefik): refuse a named upload wildcard that covers a host an unnamed route owns (D-0617) --- .github/wiki/plugins/Traefik.md | 2 +- free/traefik/README.md | 4 +- free/traefik/internal/render/render.go | 1 + free/traefik/internal/render/upload.go | 54 +++++++++++++++++++- free/traefik/internal/render/upload_test.go | 55 +++++++++++++++++++++ 5 files changed, 113 insertions(+), 3 deletions(-) diff --git a/.github/wiki/plugins/Traefik.md b/.github/wiki/plugins/Traefik.md index d5216258b..f06d8488a 100644 --- a/.github/wiki/plugins/Traefik.md +++ b/.github/wiki/plugins/Traefik.md @@ -43,7 +43,7 @@ Traefik's `readTimeout` is per entrypoint and is one total deadline (also the on | `TRAEFIK_UPLOAD_READ_TIMEOUT` | `600s` | `readTimeout` of the upload entrypoint, 60 s to 24 h in whole seconds; anything else exits 1 with nothing written | | `TRAEFIK_UPLOAD_PUBLISH` | `127.0.0.1:` | host side of the :8444 mapping. The default publishes on a loopback ephemeral port, so nothing public opens; set `8444` (or `0.0.0.0:8444`) to expose it | -Each named route is served on `upload` with the same routers, chains, rules and priorities as on its normal listener (the secure listener when the route has one, else the plain one). The default server has no routers on `upload`: an unknown Host there gets Traefik's own 404, and so does a route that was not named. The static configuration is read at start, so changing any of these needs the stack restarted (`nself restart`), not only a re-render. Parity: an 80 s trickle upload to the named route passes on `upload` (120 s), is still cut on :80, and every other route answers 404 on :8444. +Each named route is served on `upload` with the same routers, chains, rules and priorities as on its normal listener (the secure listener when the route has one, else the plain one). The default server has no routers on `upload`: an unknown Host there gets Traefik's own 404, and so does a route that was not named. Because an unnamed route has no router there, the renderer refuses (exit 1, nothing written) a named wildcard route that covers a host an unnamed route owns more specifically (an exact name, or a longer wildcard); name that route as well. The static configuration is read at start, so changing any of these needs the stack restarted (`nself restart`), not only a re-render. Parity: an 80 s trickle upload to the named route passes on `upload` (120 s), is still cut on :80, and every other route answers 404 on :8444. ## Parity scope diff --git a/free/traefik/README.md b/free/traefik/README.md index 7de77b1c0..f1a64536f 100644 --- a/free/traefik/README.md +++ b/free/traefik/README.md @@ -14,7 +14,9 @@ Upload entrypoint (optional). Traefik's `readTimeout` is a total deadline per en named routes take long uploads, set in the stack environment: `TRAEFIK_UPLOAD_ROUTES` (comma-separated route ids, empty = off), `TRAEFIK_UPLOAD_READ_TIMEOUT` (default `600s`, 60s to 24h) and `TRAEFIK_UPLOAD_PUBLISH` (default `127.0.0.1:`, a loopback ephemeral port; set `8444` to open it). Container port :8444 carries only the named routes (an unknown Host there -is Traefik's own 404, there is no default server). The static configuration is read at start: restart the stack after +is Traefik's own 404, there is no default server). Because an unnamed route has no router there, `render` refuses a named +wildcard route that covers a host an unnamed route owns more specifically (an exact name, or a longer wildcard): name that +route as well, or the host would be answered by the wildcard's upstream instead of its own route. The static configuration is read at start: restart the stack after changing them. The image runs as the non-root user 65532. The one-shot `traefik-config` runs as `NSELF_HOST_UID:NSELF_HOST_GID`, exported by the CLI on Linux (`0:0` where it is not exported, such as Docker Desktop). On Linux, the CLI must pre-create `.nself/generated/traefik` and `.nself/generated/traefik-static` as the host operator before starting the diff --git a/free/traefik/internal/render/render.go b/free/traefik/internal/render/render.go index ffd8d6201..895658330 100644 --- a/free/traefik/internal/render/render.go +++ b/free/traefik/internal/render/render.go @@ -98,6 +98,7 @@ func Render(m *contract.Model, o Options) ([]byte, error) { b.route(r) } b.checkUpload() + b.checkUploadOverlap() b.cur, b.tier, b.noLog = "-", 0, false b.defaultServer() if len(b.refs) > 0 { diff --git a/free/traefik/internal/render/upload.go b/free/traefik/internal/render/upload.go index 9d23eeec0..801da4164 100644 --- a/free/traefik/internal/render/upload.go +++ b/free/traefik/internal/render/upload.go @@ -1,6 +1,10 @@ package render -import "github.com/nself-org/plugins/free/traefik/internal/contract" +import ( + "strings" + + "github.com/nself-org/plugins/free/traefik/internal/contract" +) // Upload routes: a named route is also served on the "upload" entrypoint, a separate Traefik // listener whose readTimeout is configurable (internal/staticcfg), so a long upload to that route @@ -13,6 +17,8 @@ const uploadEntry = "upload" // it has one (same TLS options), else of its plain one. Every router the route emits for its // listeners is then emitted on the upload entrypoint too, with the same chain, rule and priority. // The default server has no routers there: an unknown Host on that port gets Traefik's own 404. +// A host owned by an unnamed route has no router there either, so a named wildcard that covers it +// would answer it with the wildcard's own upstream: checkUpload refuses that overlap. func (b *builder) withUpload(r contract.Route, ls []listener) []listener { if !b.upload[r.ID] { return ls @@ -49,3 +55,49 @@ func (b *builder) checkUpload() { } } } + +// wildSuffix is the suffix (".example.test") a server name covers, or "" for an exact name. +func wildSuffix(n string) string { + n = strings.ToLower(n) + if strings.HasPrefix(n, "*.") { + return n[1:] + } + if strings.HasPrefix(n, ".") { + return n + } + return "" +} + +// checkUploadOverlap refuses a named route whose wildcard covers a server name that an unnamed +// route owns more specifically (an exact name, or a wildcard with a longer suffix). On the web +// listeners the unnamed route wins that host; on the upload listener it has no router, so the +// wildcard would answer it. Name the overlapping route too, or drop the wildcard. +func (b *builder) checkUploadOverlap() { + for _, r := range b.m.Routes { + if !b.upload[r.ID] { + continue + } + for _, wn := range r.ServerNames { + s := wildSuffix(wn) + if s == "" { + continue + } + for _, o := range b.m.Routes { + if b.upload[o.ID] { + continue + } + for _, on := range o.ServerNames { + lo := strings.ToLower(on) + os := wildSuffix(on) + covered := os == "" && strings.HasSuffix(lo, s) // exact name under the suffix + if os != "" { + covered = len(os) > len(s) && strings.HasSuffix(os, s) + } + if covered { + b.refuse(r.ID, "upload_routes", "unnamed route "+o.ID+" owns "+on+", which this wildcard would answer on the upload port") + } + } + } + } + } +} diff --git a/free/traefik/internal/render/upload_test.go b/free/traefik/internal/render/upload_test.go index 419fd8ae0..640f79787 100644 --- a/free/traefik/internal/render/upload_test.go +++ b/free/traefik/internal/render/upload_test.go @@ -107,3 +107,58 @@ func TestUnknownUploadRouteIsRefused(t *testing.T) { t.Fatalf("want one upload_routes refusal for nope:1, got %v", err) } } + +// The upload listener mirrors only the named routes, so a named wildcard would answer, with its own +// upstream, a host that an unnamed route owns on web and websecure (D-0617). That is refused. +func TestUploadWildcardOverlappingAnUnnamedRouteIsRefused(t *testing.T) { + const wild = "plugin:wild/wild-wild.conf#1" + _, err := renderUpload(t, "host-tier", wild) + var ref *RefusalError + if !errors.As(err, &ref) { + t.Fatalf("want a refusal, got %v", err) + } + var got []string + for _, it := range ref.Items { + if it.Route != wild || it.Field != "upload_routes" { + t.Errorf("unexpected refusal %+v", it) + } + got = append(got, it.Reason) + } + want := "unnamed route plugin:notes/notes.conf#1 owns notes.example.test, which this wildcard would answer on the upload port" + found := false + for _, g := range got { + found = found || g == want + } + if !found { + t.Fatalf("missing %q in %q", want, got) + } +} + +// Naming the overlapping routes too (or a wildcard with nothing under it) renders. +func TestUploadWildcardWithEveryOverlapNamedRenders(t *testing.T) { + m := fixture(t, "host-tier") + var ids []string + for _, r := range m.Routes { + ids = append(ids, r.ID) + } + if _, err := Render(m, Options{SSLRoot: sslTree(t, m), CertRoot: "/nself/ssl", Upload: ids}); err != nil { + t.Fatalf("all routes named: %v", err) + } + // a more specific wildcard owned by an unnamed route also overlaps + m = fixture(t, "host-tier") + deep := noteRoute(t, m) + deep.ID, deep.ServerNames = "plugin:deep/deep.conf#1", []string{"*.deep.example.test"} + m.Routes = append(m.Routes, deep) + _, err := Render(m, Options{Upload: []string{"plugin:wild/wild-wild.conf#1", "core:auth", "core:hasura", "core:storage", "core:storage-console", "plugin:notes/notes.conf#1", "core:api-docs"}}) + var ref *RefusalError + if !errors.As(err, &ref) || len(ref.Items) != 1 || !strings.Contains(ref.Items[0].Reason, "*.deep.example.test") { + t.Fatalf("want one refusal naming *.deep.example.test, got %v", err) + } + // a wildcard on an unrelated suffix does not overlap + m = fixture(t, "host-tier") + m.Routes = append(m.Routes, deep) + m.Routes[len(m.Routes)-1].ServerNames = []string{"*.other.test"} + if _, err := Render(m, Options{Upload: []string{"core:auth"}}); err != nil { + t.Fatalf("exact route named, unrelated wildcard unnamed: %v", err) + } +} From b27e300190635e2c10dd8501cf96a2f89cfede02 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 13:53:58 -0400 Subject: [PATCH 3/5] chore(plugins): delete the dead v1 shell plugin framework and its doc references (D-0297) --- .github/file-size-budget.txt | 7 - .github/wiki/Contributing.md | 3 +- .github/wiki/Development.md | 253 +- .github/wiki/architecture/Plugin-System.md | 4 +- .github/wiki/plugins/File-Processing.md | 5 +- .github/wiki/plugins/IDme.md | 12 +- .github/wiki/plugins/Notifications.md | 3 +- .../security/redteam/d_plugins_static_test.go | 13 +- .../file-processing/IMPLEMENTATION-SUMMARY.md | 19 - free/file-processing/QUICKSTART.md | 3 +- free/file-processing/README.md | 3 +- free/file-processing/actions/cleanup.sh | 27 - free/file-processing/actions/init.sh | 56 - free/file-processing/actions/process.sh | 80 - free/file-processing/actions/server.sh | 34 - free/file-processing/actions/stats.sh | 86 - free/file-processing/actions/worker.sh | 34 - free/file-processing/install.sh | 142 -- free/file-processing/uninstall.sh | 63 - free/github-runner/README.md | 4 +- free/github-runner/actions/logs.sh | 32 - free/github-runner/actions/start.sh | 70 - free/github-runner/actions/status.sh | 111 - free/github-runner/actions/stop.sh | 55 - free/github-runner/actions/trust.sh | 323 --- free/github-runner/actions/update.sh | 32 - free/github-runner/install.sh | 285 --- .../github-runner/tests/github-runner.test.sh | 352 --- free/github-runner/uninstall.sh | 144 -- free/github/actions/issues.sh | 136 -- free/github/actions/prs.sh | 141 -- free/github/actions/repos.sh | 150 -- free/github/actions/sync.sh | 422 ---- free/github/actions/webhook.sh | 169 -- free/github/actions/workflows.sh | 135 -- free/github/install.sh | 198 -- free/github/uninstall.sh | 115 - free/github/webhooks/events/default.sh | 24 - free/github/webhooks/events/issues.sh | 82 - free/github/webhooks/events/pull_request.sh | 97 - free/github/webhooks/events/push.sh | 60 - free/github/webhooks/events/workflow_run.sh | 85 - free/github/webhooks/handler.sh | 146 -- free/idme/PLUGIN_SUMMARY.md | 26 - free/idme/QUICKSTART.md | 3 +- free/idme/README.md | 5 +- free/idme/actions/groups.sh | 126 - free/idme/actions/init.sh | 129 - free/idme/actions/test.sh | 182 -- free/idme/actions/verify.sh | 147 -- free/idme/install.sh | 94 - free/idme/schema/tables.sql | 2 +- free/idme/uninstall.sh | 63 - free/idme/webhooks/events/default.sh | 34 - free/idme/webhooks/events/group.verified.sh | 57 - .../webhooks/events/verification.updated.sh | 37 - free/idme/webhooks/handler.sh | 116 - free/jobs/PLUGIN-SUMMARY.md | 12 +- free/jobs/actions/init.sh | 124 - free/jobs/actions/retry.sh | 235 -- free/jobs/actions/schedule.sh | 310 --- free/jobs/actions/server.sh | 76 - free/jobs/actions/stats.sh | 269 --- free/jobs/actions/worker.sh | 94 - free/jobs/install.sh | 105 - free/jobs/uninstall.sh | 97 - free/notifications/IMPLEMENTATION_SUMMARY.md | 42 +- free/notifications/actions/init.sh | 134 -- free/notifications/actions/server.sh | 96 - free/notifications/actions/stats.sh | 308 --- free/notifications/actions/template.sh | 298 --- free/notifications/actions/test.sh | 210 -- free/notifications/actions/worker.sh | 95 - free/notifications/install.sh | 76 - free/notifications/uninstall.sh | 86 - free/paypal/install.sh | 78 - free/shopify/actions/customers.sh | 160 -- free/shopify/actions/orders.sh | 169 -- free/shopify/actions/products.sh | 151 -- free/shopify/actions/sync.sh | 376 --- free/shopify/actions/webhook.sh | 128 - free/shopify/install.sh | 169 -- free/shopify/uninstall.sh | 79 - free/shopify/webhooks/events/default.sh | 18 - free/shopify/webhooks/events/orders_create.sh | 91 - .../webhooks/events/products_update.sh | 78 - free/shopify/webhooks/handler.sh | 116 - free/stripe/actions/customers.sh | 194 -- free/stripe/actions/invoices.sh | 212 -- free/stripe/actions/subscriptions.sh | 192 -- free/stripe/actions/sync.sh | 381 --- free/stripe/actions/webhook.sh | 249 -- free/stripe/install.sh | 77 - free/stripe/uninstall.sh | 86 - .../webhooks/events/customer_created.sh | 63 - .../webhooks/events/customer_updated.sh | 54 - free/stripe/webhooks/events/default.sh | 35 - free/stripe/webhooks/events/invoice_paid.sh | 54 - .../events/payment_intent_succeeded.sh | 56 - .../webhooks/events/subscription_created.sh | 65 - free/stripe/webhooks/handler.sh | 221 -- shared/generate-plugin.sh | 2069 ----------------- shared/plugin-utils.sh | 719 ------ shared/schema-sync.sh | 262 --- shared/webhook-handler.sh | 192 -- 105 files changed, 37 insertions(+), 14660 deletions(-) delete mode 100755 free/file-processing/actions/cleanup.sh delete mode 100755 free/file-processing/actions/init.sh delete mode 100755 free/file-processing/actions/process.sh delete mode 100755 free/file-processing/actions/server.sh delete mode 100755 free/file-processing/actions/stats.sh delete mode 100755 free/file-processing/actions/worker.sh delete mode 100755 free/file-processing/install.sh delete mode 100755 free/file-processing/uninstall.sh delete mode 100755 free/github-runner/actions/logs.sh delete mode 100755 free/github-runner/actions/start.sh delete mode 100755 free/github-runner/actions/status.sh delete mode 100755 free/github-runner/actions/stop.sh delete mode 100644 free/github-runner/actions/trust.sh delete mode 100755 free/github-runner/actions/update.sh delete mode 100755 free/github-runner/install.sh delete mode 100644 free/github-runner/tests/github-runner.test.sh delete mode 100755 free/github-runner/uninstall.sh delete mode 100644 free/github/actions/issues.sh delete mode 100644 free/github/actions/prs.sh delete mode 100644 free/github/actions/repos.sh delete mode 100644 free/github/actions/sync.sh delete mode 100644 free/github/actions/webhook.sh delete mode 100644 free/github/actions/workflows.sh delete mode 100644 free/github/install.sh delete mode 100644 free/github/uninstall.sh delete mode 100644 free/github/webhooks/events/default.sh delete mode 100644 free/github/webhooks/events/issues.sh delete mode 100644 free/github/webhooks/events/pull_request.sh delete mode 100644 free/github/webhooks/events/push.sh delete mode 100644 free/github/webhooks/events/workflow_run.sh delete mode 100644 free/github/webhooks/handler.sh delete mode 100755 free/idme/actions/groups.sh delete mode 100755 free/idme/actions/init.sh delete mode 100755 free/idme/actions/test.sh delete mode 100755 free/idme/actions/verify.sh delete mode 100755 free/idme/install.sh delete mode 100755 free/idme/uninstall.sh delete mode 100755 free/idme/webhooks/events/default.sh delete mode 100755 free/idme/webhooks/events/group.verified.sh delete mode 100755 free/idme/webhooks/events/verification.updated.sh delete mode 100755 free/idme/webhooks/handler.sh delete mode 100755 free/jobs/actions/init.sh delete mode 100755 free/jobs/actions/retry.sh delete mode 100755 free/jobs/actions/schedule.sh delete mode 100755 free/jobs/actions/server.sh delete mode 100755 free/jobs/actions/stats.sh delete mode 100755 free/jobs/actions/worker.sh delete mode 100755 free/jobs/install.sh delete mode 100755 free/jobs/uninstall.sh delete mode 100755 free/notifications/actions/init.sh delete mode 100755 free/notifications/actions/server.sh delete mode 100755 free/notifications/actions/stats.sh delete mode 100755 free/notifications/actions/template.sh delete mode 100755 free/notifications/actions/test.sh delete mode 100755 free/notifications/actions/worker.sh delete mode 100755 free/notifications/install.sh delete mode 100755 free/notifications/uninstall.sh delete mode 100755 free/paypal/install.sh delete mode 100644 free/shopify/actions/customers.sh delete mode 100644 free/shopify/actions/orders.sh delete mode 100644 free/shopify/actions/products.sh delete mode 100644 free/shopify/actions/sync.sh delete mode 100644 free/shopify/actions/webhook.sh delete mode 100644 free/shopify/install.sh delete mode 100644 free/shopify/uninstall.sh delete mode 100644 free/shopify/webhooks/events/default.sh delete mode 100644 free/shopify/webhooks/events/orders_create.sh delete mode 100644 free/shopify/webhooks/events/products_update.sh delete mode 100644 free/shopify/webhooks/handler.sh delete mode 100644 free/stripe/actions/customers.sh delete mode 100644 free/stripe/actions/invoices.sh delete mode 100644 free/stripe/actions/subscriptions.sh delete mode 100644 free/stripe/actions/sync.sh delete mode 100644 free/stripe/actions/webhook.sh delete mode 100644 free/stripe/install.sh delete mode 100644 free/stripe/uninstall.sh delete mode 100644 free/stripe/webhooks/events/customer_created.sh delete mode 100644 free/stripe/webhooks/events/customer_updated.sh delete mode 100644 free/stripe/webhooks/events/default.sh delete mode 100644 free/stripe/webhooks/events/invoice_paid.sh delete mode 100644 free/stripe/webhooks/events/payment_intent_succeeded.sh delete mode 100644 free/stripe/webhooks/events/subscription_created.sh delete mode 100644 free/stripe/webhooks/handler.sh delete mode 100755 shared/generate-plugin.sh delete mode 100644 shared/plugin-utils.sh delete mode 100644 shared/schema-sync.sh delete mode 100644 shared/webhook-handler.sh diff --git a/.github/file-size-budget.txt b/.github/file-size-budget.txt index 1cfff1318..f5f89c761 100644 --- a/.github/file-size-budget.txt +++ b/.github/file-size-budget.txt @@ -52,12 +52,10 @@ free/geocoding/internal/handlers.go 1087 free/geocoding/internal/provider/provider.go 406 free/github-runner/third_party/sdk/devkit/cmd/new-plugin/main.go 510 free/github-runner/third_party/sdk/identity.go 338 -free/github/actions/sync.sh 422 free/idme/internal/handlers.go 536 free/invitations/third_party/sdk/devkit/cmd/new-plugin/main.go 510 free/invitations/third_party/sdk/identity.go 338 free/job-queue/go/api/handlers.go 356 -free/jobs/actions/schedule.sh 310 free/link-preview/third_party/sdk/devkit/cmd/new-plugin/main.go 510 free/link-preview/third_party/sdk/identity.go 338 free/linkedin/internal/handlers.go 346 @@ -75,7 +73,6 @@ free/media-processing/ts/src/watcher.ts 320 free/meetings/internal/handlers.go 925 free/mlflow/third_party/sdk/devkit/cmd/new-plugin/main.go 510 free/mlflow/third_party/sdk/identity.go 338 -free/notifications/actions/stats.sh 308 free/notifications/sdk/devkit/cmd/new-plugin/main.go 510 free/notifications/sdk/identity.go 338 free/notify/third_party/sdk/devkit/cmd/new-plugin/main.go 510 @@ -103,11 +100,9 @@ free/retro-gaming/internal/handlers.go 996 free/rom-discovery/internal/handlers.go 526 free/search/sdk/devkit/cmd/new-plugin/main.go 510 free/search/sdk/identity.go 338 -free/shopify/actions/sync.sh 376 free/shopify/third_party/sdk/devkit/cmd/new-plugin/main.go 510 free/shopify/third_party/sdk/identity.go 338 free/sports/internal/handlers.go 876 -free/stripe/actions/sync.sh 381 free/stripe/internal/db_schema.go 761 free/subtitle-manager/third_party/sdk/devkit/cmd/new-plugin/main.go 510 free/subtitle-manager/third_party/sdk/identity.go 338 @@ -130,8 +125,6 @@ free/webhooks/sdk/identity.go 338 free/workflows/internal/handlers.go 721 sdk/devkit/cmd/new-plugin/main.go 510 sdk/identity.go 338 -shared/generate-plugin.sh 2069 -shared/plugin-utils.sh 719 shared/src/database.ts 306 shared/src/webhook.ts 323 shared/validate-registry.sh 1020 diff --git a/.github/wiki/Contributing.md b/.github/wiki/Contributing.md index bd6cd134f..575584078 100644 --- a/.github/wiki/Contributing.md +++ b/.github/wiki/Contributing.md @@ -44,7 +44,6 @@ Plugins live in `free/`, `community/`, and `monitoring/` directories. Each plugi ``` free// plugin.json # Plugin manifest - install.sh # Installation script README.md # Plugin-specific docs docker-compose.yml # If adding a service ts/src/ # TypeScript source @@ -150,7 +149,7 @@ All checks must pass or your PR will fail automated validation. 1. Fork the repository 2. Create a feature branch from `main` -3. New plugins need a `plugin.json`, `install.sh`, and README +3. New plugins need a `plugin.json`, a Go `cmd/` entry point, and a README 4. Make your changes, following existing plugin patterns 5. Test with a local nSelf stack 6. Submit a PR with a clear description of what changed and why diff --git a/.github/wiki/Development.md b/.github/wiki/Development.md index c25b840d6..c5a85a929 100644 --- a/.github/wiki/Development.md +++ b/.github/wiki/Development.md @@ -24,26 +24,25 @@ Every plugin follows this structure: ``` plugins// ├── plugin.json # Plugin metadata (required) -├── install.sh # Installation script (required) -├── uninstall.sh # Uninstallation script (required) +├── cmd/ # Go service or CLI entry point (required) +├── migrations/ # Embedded SQL migrations, applied at boot (required for plugins with tables) ├── schema/ -│ ├── tables.sql # Database schema (required) -│ └── migrations/ # Schema migrations (optional) -├── webhooks/ -│ ├── handler.sh # Webhook dispatcher (optional) -│ └── events/ # Event handlers (optional) -├── actions/ -│ └── *.sh # CLI actions (optional) -└── templates/ - └── *.template # Config templates (optional) +│ └── tables.sql # Database schema reference +├── Dockerfile +└── docker-compose.plugin.yml ``` +A plugin ships a binary. The CLI never runs per-plugin `install.sh`, `uninstall.sh`, `actions/*.sh` or +`webhooks/**/*.sh` scripts, and the repository carries none. Install, upgrade and removal go through +`nself plugin install|upgrade|remove`; migrations run when the service starts. Start from +`nself plugin new` and follow [Plugin Development](Plugin-Development.md). + ## Creating a Plugin ### 1. Create Plugin Directory ```bash -mkdir -p plugins/my-service/{schema,webhooks/events,actions,templates} +mkdir -p plugins/my-service/{cmd,migrations,schema} ``` ### 2. Create plugin.json @@ -145,236 +144,6 @@ CREATE INDEX IF NOT EXISTS idx_np_myservice_webhooks_account - Use UUID for primary keys (recommended) - Include `created_at` and `synced_at` timestamps -### 4. Create Install Script - -Create `install.sh`: - -```bash -#!/bin/bash -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" -source "${SHARED_DIR}/schema-sync.sh" - -install_plugin() { - plugin_info "Installing my-service plugin..." - - # Check environment - if ! plugin_check_env "myservice" "MYSERVICE_API_KEY"; then - plugin_warn "MYSERVICE_API_KEY not set" - fi - - # Apply schema - plugin_db_exec_file "${PLUGIN_DIR}/schema/tables.sql" - - plugin_success "Plugin installed!" -} - -install_plugin -``` - -### 5. Create Uninstall Script - -Create `uninstall.sh`: - -```bash -#!/bin/bash -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -uninstall_plugin() { - local keep_data="${1:-false}" - - plugin_info "Uninstalling my-service plugin..." - - if [[ "$keep_data" != "true" ]]; then - plugin_db_query "DROP TABLE IF EXISTS myservice_webhooks CASCADE;" - plugin_db_query "DROP TABLE IF EXISTS myservice_items CASCADE;" - fi - - plugin_success "Plugin uninstalled!" -} - -uninstall_plugin "${1:-false}" -``` - -## Adding Actions - -Actions are CLI commands users can run. - -### Create an Action - -Create `actions/sync.sh`: - -```bash -#!/bin/bash -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -MYSERVICE_API_KEY="${MYSERVICE_API_KEY:-}" -MYSERVICE_API_BASE="https://api.myservice.com/v1" - -api_request() { - local endpoint="$1" - curl -s \ - -H "Authorization: Bearer ${MYSERVICE_API_KEY}" \ - "${MYSERVICE_API_BASE}/${endpoint}" -} - -sync_items() { - plugin_info "Syncing items..." - - local response - response=$(api_request "items") - - # Process and insert items - # ... - - plugin_success "Sync complete!" -} - -case "${1:-sync}" in - sync) sync_items ;; - -h|--help) echo "Usage: nself plugin my-service sync" ;; - *) echo "Unknown command: $1" ;; -esac -``` - -## Adding Webhook Support - -### 1. Create Webhook Handler - -Create `webhooks/handler.sh`: - -```bash -#!/bin/bash -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -process_webhook() { - local payload="$1" - - # Extract event type - local event_type - event_type=$(plugin_json_get "$payload" "type") - - plugin_info "Received: $event_type" - - # Record event - local event_id - event_id=$(plugin_json_get "$payload" "id") - - plugin_db_query " - INSERT INTO myservice_webhooks (id, type, data) - VALUES ('$event_id', '$event_type', '$payload'::jsonb); - " - - # Dispatch to event handler - local handler="${PLUGIN_DIR}/webhooks/events/${event_type}.sh" - if [[ -f "$handler" ]]; then - bash "$handler" "$payload" - fi -} - -process_webhook "$1" -``` - -### 2. Create Event Handlers - -Create `webhooks/events/item_created.sh`: - -```bash -#!/bin/bash -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -handle_item_created() { - local payload="$1" - - local item_id name - item_id=$(plugin_json_get "$payload" "item_id") - name=$(plugin_json_get "$payload" "name") - - plugin_db_query " - INSERT INTO myservice_items (id, name, synced_at) - VALUES ('$item_id', '$name', NOW()) - ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, synced_at = NOW(); - " - - plugin_success "Created item: $item_id" -} - -handle_item_created "$@" -``` - -## Shared Utilities - -Use the shared utilities for common operations: - -### Database Operations - -```bash -# Execute query -plugin_db_query "SELECT * FROM table;" - -# Execute SQL file -plugin_db_exec_file "/path/to/file.sql" - -# Check if table exists -if plugin_table_exists "myservice_items"; then - echo "Table exists" -fi -``` - -### HTTP Requests - -```bash -plugin_http_get "https://api.example.com/endpoint" -plugin_http_post "https://api.example.com/endpoint" '{"data":"value"}' -``` - -### Logging - -```bash -plugin_debug "Debug message" -plugin_info "Info message" -plugin_warn "Warning message" -plugin_error "Error message" -plugin_success "Success message" -``` - -### Caching - -```bash -# Get cached value (TTL in seconds) -value=$(plugin_cache_get "my-plugin" "key" 3600) - -# Set cached value -plugin_cache_set "my-plugin" "key" "value" - -# Clear cache -plugin_cache_clear "my-plugin" -``` - ## Testing Your Plugin ### 1. Install Locally diff --git a/.github/wiki/architecture/Plugin-System.md b/.github/wiki/architecture/Plugin-System.md index a4847c767..b0846cde7 100644 --- a/.github/wiki/architecture/Plugin-System.md +++ b/.github/wiki/architecture/Plugin-System.md @@ -1104,8 +1104,8 @@ import { StripeClient } from './client.ts'; │ ▼ ┌─────────────────────────────────────────────────────────────┐ -│ 6. Run post-install hook (if defined) │ -│ bash plugins/stripe/install.sh │ +│ 6. Post-install │ +│ (no script runs; migrations apply when the service boots)│ └─────────────────────────────────────────────────────────────┘ │ ▼ diff --git a/.github/wiki/plugins/File-Processing.md b/.github/wiki/plugins/File-Processing.md index 8ec12b8b9..7f55bf090 100644 --- a/.github/wiki/plugins/File-Processing.md +++ b/.github/wiki/plugins/File-Processing.md @@ -73,9 +73,8 @@ The File Processing plugin provides background processing for uploaded files wit ## Quick Start ```bash -# Install dependencies -cd plugins/file-processing -./install.sh +# Install the plugin +nself plugin install file-processing # Configure environment echo "FILE_STORAGE_PROVIDER=minio" >> .env diff --git a/.github/wiki/plugins/IDme.md b/.github/wiki/plugins/IDme.md index f13f4a4b1..b04631fe0 100644 --- a/.github/wiki/plugins/IDme.md +++ b/.github/wiki/plugins/IDme.md @@ -67,8 +67,7 @@ The ID.me plugin provides a complete OAuth 2.0 integration with ID.me for govern # 1. Register your application at developers.id.me # 2. Install the plugin -cd plugins/idme -./install.sh +nself plugin install idme # 3. Configure environment cp .env.example .env @@ -2892,14 +2891,7 @@ Error: Client authentication failed Error: relation "np_idme_verifications" does not exist ``` -**Solution:** Run the installer to create tables. - -```bash -cd plugins/idme -./install.sh -``` - -Or initialize via CLI: +**Solution:** The plugin applies its migrations when it starts. Initialize via CLI: ```bash nself plugin idme init diff --git a/.github/wiki/plugins/Notifications.md b/.github/wiki/plugins/Notifications.md index e65990b41..d8a546281 100644 --- a/.github/wiki/plugins/Notifications.md +++ b/.github/wiki/plugins/Notifications.md @@ -61,8 +61,7 @@ The Notifications plugin provides a complete multi-channel notification system s ```bash # Install the plugin -cd ~/Sites/nself-plugins/plugins/notifications -bash install.sh +nself plugin install notifications # Install TypeScript dependencies cd ts diff --git a/free/ci/test/security/redteam/d_plugins_static_test.go b/free/ci/test/security/redteam/d_plugins_static_test.go index a92d91a52..b45bcddb5 100644 --- a/free/ci/test/security/redteam/d_plugins_static_test.go +++ b/free/ci/test/security/redteam/d_plugins_static_test.go @@ -6,7 +6,7 @@ package redteam // attacked statically: docker-socket mounts, privileged or host-namespace settings, public port // binds, default credentials, open registration, the public-repository opt-out default, image // pinning, and whether the runner's control API is reachable without a credential. -// Inputs: docker-compose.plugin.yml, Dockerfile, install.sh and Go sources of the two plugins. +// Inputs: docker-compose.plugin.yml, Dockerfile and Go sources of the two plugins. // Outputs: pass = the dangerous setting is absent (control HELD). // Constraints: static reads only; nothing is started and no docker is used. @@ -197,14 +197,3 @@ func TestD23_RunnerControlAPIIsNotCrossOriginOpen(t *testing.T) { t.Fatal("cmd/main.go wraps the unauthenticated control routes in sdk.CORS (Allow-Origin *)") } } - -// D24: the runner tarball is verified before it is unpacked and a runner started as a service. -func TestD24_InstallVerifiesRunnerDownload(t *testing.T) { - if !openFinding(t, "D24") { - return - } - sh := dRead(t, "github-runner/install.sh") - if !strings.Contains(sh, "sha256sum") && !strings.Contains(sh, "shasum -a 256") { - t.Fatal("install.sh downloads the runner archive, unpacks it and installs it as a service without a checksum") - } -} diff --git a/free/file-processing/IMPLEMENTATION-SUMMARY.md b/free/file-processing/IMPLEMENTATION-SUMMARY.md index 309ff41f0..b535e9761 100644 --- a/free/file-processing/IMPLEMENTATION-SUMMARY.md +++ b/free/file-processing/IMPLEMENTATION-SUMMARY.md @@ -20,20 +20,10 @@ file-processing/ ├── README.md # Full documentation ├── QUICKSTART.md # Quick start guide ├── .env.example # Environment template -├── install.sh # Installation script -├── uninstall.sh # Uninstallation script │ ├── schema/ │ └── tables.sql # PostgreSQL schema (4 tables, 5 views, 3 functions) │ -├── actions/ # Shell scripts for plugin actions -│ ├── init.sh # Initialize plugin -│ ├── server.sh # Start HTTP server -│ ├── worker.sh # Start background worker -│ ├── process.sh # Process single file -│ ├── cleanup.sh # Clean up old jobs -│ └── stats.sh # View statistics -│ ├── ts/ # TypeScript implementation │ ├── package.json # npm dependencies │ ├── tsconfig.json # TypeScript config @@ -176,15 +166,6 @@ All providers support: - ✅ `stats` - View statistics - ✅ `cleanup` - Clean up old jobs -### Shell Actions - -- ✅ `init.sh` - Check dependencies and build -- ✅ `server.sh` - Start HTTP server -- ✅ `worker.sh` - Start background worker -- ✅ `process.sh` - Process file via API -- ✅ `cleanup.sh` - Database cleanup -- ✅ `stats.sh` - View statistics - ## Configuration ### Required Environment Variables diff --git a/free/file-processing/QUICKSTART.md b/free/file-processing/QUICKSTART.md index bedba64ef..260ad35c3 100644 --- a/free/file-processing/QUICKSTART.md +++ b/free/file-processing/QUICKSTART.md @@ -26,8 +26,7 @@ clamd --version ### 1. Install the Plugin ```bash -cd ~/Sites/nself-plugins/plugins/file-processing -./install.sh +nself plugin install file-processing ``` ### 2. Configure Environment diff --git a/free/file-processing/README.md b/free/file-processing/README.md index 2917c82ec..7d892b2d1 100644 --- a/free/file-processing/README.md +++ b/free/file-processing/README.md @@ -20,8 +20,7 @@ Comprehensive file processing with thumbnail generation, image optimization, vid ```bash # Install the plugin -cd plugins/file-processing -./install.sh +nself plugin install file-processing ``` ### Dependencies diff --git a/free/file-processing/actions/cleanup.sh b/free/file-processing/actions/cleanup.sh deleted file mode 100755 index 11cce14a2..000000000 --- a/free/file-processing/actions/cleanup.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -# ============================================================================= -# File Processing - Cleanup Old Jobs -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# Parse arguments -RETENTION_DAYS="${1:-30}" - -plugin_info "Cleaning up jobs older than ${RETENTION_DAYS} days..." - -# Call cleanup function -RESULT=$(plugin_db_exec "SELECT cleanup_old_jobs(${RETENTION_DAYS});") - -if [[ $? -eq 0 ]]; then - COUNT=$(echo "${RESULT}" | tail -n 1 | tr -d ' ') - plugin_success "Cleaned up ${COUNT} old jobs" -else - plugin_error "Cleanup failed" - exit 1 -fi diff --git a/free/file-processing/actions/init.sh b/free/file-processing/actions/init.sh deleted file mode 100755 index 3f10d872c..000000000 --- a/free/file-processing/actions/init.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash -# ============================================================================= -# File Processing - Initialize -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -plugin_info "Initializing File Processing plugin..." - -# Check dependencies -plugin_info "Checking dependencies..." - -if ! command -v node >/dev/null 2>&1; then - plugin_error "Node.js is required but not installed" - exit 1 -fi - -if ! command -v npm >/dev/null 2>&1; then - plugin_error "npm is required but not installed" - exit 1 -fi - -# Install and build TypeScript -cd "${PLUGIN_DIR}/ts" - -if [[ ! -d "node_modules" ]]; then - plugin_info "Installing dependencies..." - npm install -fi - -if [[ ! -d "dist" ]]; then - plugin_info "Building TypeScript..." - npm run build -fi - -# Check optional dependencies -if command -v ffmpeg >/dev/null 2>&1; then - plugin_success "✓ ffmpeg found (video thumbnails enabled)" -else - plugin_warn "⚠ ffmpeg not found (video thumbnails disabled)" -fi - -if command -v clamdscan >/dev/null 2>&1 || command -v clamd >/dev/null 2>&1; then - plugin_success "✓ ClamAV found (virus scanning available)" -else - plugin_warn "⚠ ClamAV not found (virus scanning disabled)" -fi - -plugin_success "File Processing plugin initialized!" -plugin_info "Start server: nself plugin file-processing server" -plugin_info "Start worker: nself plugin file-processing worker" diff --git a/free/file-processing/actions/process.sh b/free/file-processing/actions/process.sh deleted file mode 100755 index 197687d13..000000000 --- a/free/file-processing/actions/process.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/bin/bash -# ============================================================================= -# File Processing - Process File -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# Parse arguments -if [[ $# -lt 2 ]]; then - plugin_error "Usage: nself plugin file-processing process " - exit 1 -fi - -FILE_ID="$1" -FILE_PATH="$2" - -cd "${PLUGIN_DIR}/ts" - -# Check if built -if [[ ! -d "dist" ]]; then - plugin_error "Plugin not built. Run: nself plugin file-processing init" - exit 1 -fi - -# Load environment -if [[ -f ".env" ]]; then - set -a - source .env - set +a -fi - -plugin_info "Processing file: ${FILE_PATH}" -plugin_info "File ID: ${FILE_ID}" - -# Create job via API -PORT="${PORT:-3104}" -FILENAME=$(basename "${FILE_PATH}") - -# Get file size and MIME type -if [[ -f "${FILE_PATH}" ]]; then - FILE_SIZE=$(stat -f%z "${FILE_PATH}" 2>/dev/null || stat -c%s "${FILE_PATH}" 2>/dev/null || echo "0") - MIME_TYPE=$(file --mime-type -b "${FILE_PATH}" 2>/dev/null || echo "application/octet-stream") -else - plugin_warn "File not found locally, using defaults" - FILE_SIZE=0 - MIME_TYPE="application/octet-stream" -fi - -# Create job -RESPONSE=$(curl -s -X POST "http://localhost:${PORT}/api/jobs" \ - -H "Content-Type: application/json" \ - -d "{ - \"fileId\": \"${FILE_ID}\", - \"filePath\": \"${FILE_PATH}\", - \"fileName\": \"${FILENAME}\", - \"fileSize\": ${FILE_SIZE}, - \"mimeType\": \"${MIME_TYPE}\", - \"operations\": [\"thumbnail\", \"optimize\", \"metadata\"] - }" 2>&1) - -if [[ $? -eq 0 ]]; then - JOB_ID=$(echo "${RESPONSE}" | grep -o '"jobId":"[^"]*"' | cut -d'"' -f4) - if [[ -n "${JOB_ID}" ]]; then - plugin_success "Job created: ${JOB_ID}" - plugin_info "Check status: curl http://localhost:${PORT}/api/jobs/${JOB_ID}" - else - plugin_error "Failed to create job" - echo "${RESPONSE}" - exit 1 - fi -else - plugin_error "Failed to connect to server (is it running?)" - plugin_info "Start server: nself plugin file-processing server" - exit 1 -fi diff --git a/free/file-processing/actions/server.sh b/free/file-processing/actions/server.sh deleted file mode 100755 index 1f06c7ad9..000000000 --- a/free/file-processing/actions/server.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -# ============================================================================= -# File Processing - Start Server -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -cd "${PLUGIN_DIR}/ts" - -# Check if built -if [[ ! -d "dist" ]]; then - plugin_error "Plugin not built. Run: nself plugin file-processing init" - exit 1 -fi - -# Load environment -if [[ -f ".env" ]]; then - set -a - source .env - set +a -fi - -PORT="${PORT:-3104}" - -plugin_info "Starting File Processing server on port ${PORT}..." -plugin_info "Press Ctrl+C to stop" - -# Start server -node dist/server.js diff --git a/free/file-processing/actions/stats.sh b/free/file-processing/actions/stats.sh deleted file mode 100755 index 7673a3f2d..000000000 --- a/free/file-processing/actions/stats.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash -# ============================================================================= -# File Processing - Statistics -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -plugin_info "File Processing Statistics" -echo "" - -# Overall stats -plugin_info "Job Status:" -plugin_db_exec " -SELECT - status, - COUNT(*) AS count, - ROUND(AVG(duration_ms)) AS avg_duration_ms -FROM file_processing_jobs -GROUP BY status -ORDER BY - CASE status - WHEN 'processing' THEN 1 - WHEN 'pending' THEN 2 - WHEN 'completed' THEN 3 - WHEN 'failed' THEN 4 - WHEN 'cancelled' THEN 5 - END; -" | column -t - -echo "" - -# Thumbnail stats -plugin_info "Thumbnail Generation:" -plugin_db_exec " -SELECT - width || 'x' || height AS size, - format, - COUNT(*) AS count, - ROUND(AVG(generation_time_ms)) AS avg_time_ms, - pg_size_pretty(SUM(size_bytes)::BIGINT) AS total_size -FROM file_thumbnails -GROUP BY width, height, format -ORDER BY width; -" | column -t - -echo "" - -# Scan stats (if enabled) -SCAN_COUNT=$(plugin_db_exec "SELECT COUNT(*) FROM file_scans;" | tail -n 1 | tr -d ' ') -if [[ "${SCAN_COUNT}" -gt 0 ]]; then - plugin_info "Virus Scans:" - plugin_db_exec " - SELECT - scan_status AS status, - COUNT(*) AS count, - ROUND(AVG(scan_duration_ms)) AS avg_duration_ms - FROM file_scans - GROUP BY scan_status - ORDER BY count DESC; - " | column -t - echo "" -fi - -# Recent failures -FAILURE_COUNT=$(plugin_db_exec "SELECT COUNT(*) FROM file_processing_failures;" | tail -n 1 | tr -d ' ') -if [[ "${FAILURE_COUNT}" -gt 0 ]]; then - plugin_warn "Recent Failures: ${FAILURE_COUNT}" - plugin_db_exec " - SELECT - file_name, - status, - attempts, - error_message, - last_error_at - FROM file_processing_failures - LIMIT 10; - " | column -t - echo "" -fi - -plugin_success "Statistics complete" diff --git a/free/file-processing/actions/worker.sh b/free/file-processing/actions/worker.sh deleted file mode 100755 index 63868b2b8..000000000 --- a/free/file-processing/actions/worker.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -# ============================================================================= -# File Processing - Start Worker -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -cd "${PLUGIN_DIR}/ts" - -# Check if built -if [[ ! -d "dist" ]]; then - plugin_error "Plugin not built. Run: nself plugin file-processing init" - exit 1 -fi - -# Load environment -if [[ -f ".env" ]]; then - set -a - source .env - set +a -fi - -CONCURRENCY="${FILE_QUEUE_CONCURRENCY:-3}" - -plugin_info "Starting File Processing worker (concurrency: ${CONCURRENCY})..." -plugin_info "Press Ctrl+C to stop" - -# Start worker -node dist/worker.js diff --git a/free/file-processing/install.sh b/free/file-processing/install.sh deleted file mode 100755 index 39e9ebb6d..000000000 --- a/free/file-processing/install.sh +++ /dev/null @@ -1,142 +0,0 @@ -#!/bin/bash -# ============================================================================= -# File Processing Plugin Installer -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" -source "${SHARED_DIR}/schema-sync.sh" - -# ============================================================================= -# Installation -# ============================================================================= - -# ts/ depends on ../../../shared through a link: specifier, which npm cannot -# install (EUNSUPPORTEDPROTOCOL); pnpm 9, 10 and 11 can. A failed install or -# build fails the installer: it never reports success over a missing build. -install_typescript_dependencies() { - [[ -d "${PLUGIN_DIR}/ts" ]] || return 0 - if ! command -v pnpm >/dev/null 2>&1; then - plugin_error "pnpm is required to install the TypeScript dependencies (npm cannot install the link: dependency). See https://pnpm.io/installation" - return 1 - fi - plugin_info "Installing TypeScript dependencies..." - # The ts build compiles shared/ first, which needs shared's own dependencies. - if ! (cd "$SHARED_DIR" && pnpm install --frozen-lockfile); then - plugin_error "Failed to install the shared dependencies" - return 1 - fi - if ! (cd "${PLUGIN_DIR}/ts" && pnpm install --frozen-lockfile); then - plugin_error "Failed to install the TypeScript dependencies" - return 1 - fi - if ! (cd "${PLUGIN_DIR}/ts" && pnpm run build); then - plugin_error "Failed to build the TypeScript sources" - return 1 - fi -} - -install_file_processing_plugin() { - plugin_info "Installing File Processing plugin..." - - # Check required environment variables - if ! plugin_check_env "file-processing" "FILE_STORAGE_PROVIDER" "FILE_STORAGE_BUCKET"; then - plugin_warn "Required environment variables not set." - plugin_info "Add to your .env:" - plugin_info " FILE_STORAGE_PROVIDER=minio|s3|gcs|r2|azure|b2" - plugin_info " FILE_STORAGE_BUCKET=your-bucket-name" - plugin_info " FILE_STORAGE_ENDPOINT=http://localhost:9000 (for MinIO/S3-compatible)" - plugin_info " FILE_STORAGE_ACCESS_KEY=your-access-key" - plugin_info " FILE_STORAGE_SECRET_KEY=your-secret-key" - fi - - # Check for optional dependencies - plugin_info "Checking dependencies..." - - # Check for Sharp (image processing) - if command -v node >/dev/null 2>&1; then - plugin_info "✓ Node.js found" - if node -e "require('sharp')" 2>/dev/null; then - plugin_info "✓ Sharp (image processing) available" - else - plugin_warn "Sharp not installed. Install with: npm install sharp" - fi - else - plugin_warn "Node.js not found. Required for image processing." - fi - - # Check for ffmpeg (video processing) - if command -v ffmpeg >/dev/null 2>&1; then - plugin_info "✓ ffmpeg found (video thumbnail support enabled)" - else - plugin_warn "ffmpeg not found. Video thumbnail generation will be disabled." - plugin_info "Install: brew install ffmpeg (macOS) or apt-get install ffmpeg (Linux)" - fi - - # Check for ClamAV (virus scanning) - if [[ "${FILE_ENABLE_VIRUS_SCAN:-false}" == "true" ]]; then - if command -v clamdscan >/dev/null 2>&1 || command -v clamd >/dev/null 2>&1; then - plugin_info "✓ ClamAV found (virus scanning enabled)" - else - plugin_warn "ClamAV not found but virus scanning is enabled." - plugin_info "Install: brew install clamav (macOS) or apt-get install clamav-daemon (Linux)" - fi - fi - - # Apply database schema - plugin_info "Applying database schema..." - - # Ensure migrations table exists - schema_ensure_migrations_table - - # Apply main schema - if [[ -f "${PLUGIN_DIR}/schema/tables.sql" ]]; then - plugin_db_exec_file "${PLUGIN_DIR}/schema/tables.sql" - fi - - # Apply migrations - if [[ -d "${PLUGIN_DIR}/schema/migrations" ]]; then - for migration in "${PLUGIN_DIR}/schema/migrations"/*.sql; do - [[ ! -f "$migration" ]] && continue - - local migration_name - migration_name=$(basename "$migration" .sql) - - if ! schema_migration_applied "file-processing" "$migration_name"; then - plugin_info "Applying migration: $migration_name" - plugin_db_exec_file "$migration" - schema_record_migration "file-processing" "$migration_name" - fi - done - fi - - # Create directories - mkdir -p "${HOME}/.nself/cache/plugins/file-processing" - mkdir -p "${HOME}/.nself/logs/plugins/file-processing" - mkdir -p "${HOME}/.nself/temp/plugins/file-processing" - - install_typescript_dependencies - - plugin_success "File Processing plugin installed successfully!" - - printf "\n" - printf "Next steps:\n" - printf " 1. Configure storage provider in .env\n" - printf " 2. Run 'nself plugin file-processing server' to start the processing server\n" - printf " 3. Run 'nself plugin file-processing worker' to start the background worker\n" - printf " 4. Optional: Configure virus scanning with ClamAV\n" - printf "\n" - printf "Default thumbnail sizes: 100x100, 400x400, 1200x1200\n" - printf "Customize with: FILE_THUMBNAIL_SIZES=100,400,1200\n" - printf "\n" -} - -# Run installation (only when executed; the test sources this file) -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - install_file_processing_plugin -fi diff --git a/free/file-processing/uninstall.sh b/free/file-processing/uninstall.sh deleted file mode 100755 index eb4d24f06..000000000 --- a/free/file-processing/uninstall.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash -# ============================================================================= -# File Processing Plugin Uninstaller -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" -source "${SHARED_DIR}/schema-sync.sh" - -# ============================================================================= -# Uninstallation -# ============================================================================= - -uninstall_file_processing_plugin() { - plugin_info "Uninstalling File Processing plugin..." - - # Confirmation prompt - plugin_warn "This will remove all file processing data, thumbnails, and scan results." - printf "Are you sure? (y/N) " - read -r REPLY - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - plugin_info "Uninstall cancelled." - exit 0 - fi - - # Drop tables in reverse dependency order - plugin_info "Removing database tables..." - - plugin_db_exec "DROP VIEW IF EXISTS thumbnail_generation_stats CASCADE;" - plugin_db_exec "DROP VIEW IF EXISTS file_processing_stats CASCADE;" - plugin_db_exec "DROP VIEW IF EXISTS file_security_alerts CASCADE;" - plugin_db_exec "DROP VIEW IF EXISTS file_processing_failures CASCADE;" - plugin_db_exec "DROP VIEW IF EXISTS file_processing_queue CASCADE;" - - plugin_db_exec "DROP TABLE IF EXISTS file_metadata CASCADE;" - plugin_db_exec "DROP TABLE IF EXISTS file_scans CASCADE;" - plugin_db_exec "DROP TABLE IF EXISTS file_thumbnails CASCADE;" - plugin_db_exec "DROP TABLE IF EXISTS file_processing_jobs CASCADE;" - - plugin_db_exec "DROP FUNCTION IF EXISTS get_next_job(VARCHAR) CASCADE;" - plugin_db_exec "DROP FUNCTION IF EXISTS cleanup_old_jobs(INTEGER) CASCADE;" - plugin_db_exec "DROP FUNCTION IF EXISTS update_job_status() CASCADE;" - - # Remove migration records - schema_remove_plugin_migrations "file-processing" - - # Clean up directories - plugin_info "Cleaning up directories..." - rm -rf "${HOME}/.nself/cache/plugins/file-processing" - rm -rf "${HOME}/.nself/logs/plugins/file-processing" - rm -rf "${HOME}/.nself/temp/plugins/file-processing" - - plugin_success "File Processing plugin uninstalled successfully!" -} - -# Run uninstallation -uninstall_file_processing_plugin diff --git a/free/github-runner/README.md b/free/github-runner/README.md index 1ae4d7397..4d27e2a27 100644 --- a/free/github-runner/README.md +++ b/free/github-runner/README.md @@ -41,7 +41,7 @@ Copy `.env.example` to your nSelf `.env` file and set the values: | `GITHUB_RUNNER_PAT` | Yes | — | Fine-grained token with org "Self-hosted runners: write" (or classic `admin:org`) | | `GITHUB_RUNNER_ORG` | Yes | — | GitHub org name | | `GITHUB_RUNNER_NAME` | No | `-nself` | Runner display name | -| `GITHUB_RUNNER_LABELS` | No | `self-hosted,linux,x64,ubuntu-latest` (install.sh); `self-hosted,linux,x64` (compose service) | Comma-separated labels | +| `GITHUB_RUNNER_LABELS` | No | `self-hosted,linux,x64` (set `ubuntu-latest` too to keep `runs-on: ubuntu-latest` workflows working) | Comma-separated labels | | `GITHUB_RUNNER_SCOPE` | No | `org` | `org` or `repo` | | `GITHUB_RUNNER_REPO` | No | — | Repo name (scope=repo only) | | `GITHUB_RUNNER_GROUP` | No | the org's default group | Runner group. One open to public repositories is refused | @@ -61,7 +61,7 @@ This runner is **not isolated**. Read this before you point it at a repository. So the plugin is trusted-only by default: - Before registration, on every start and on every status call it reads the runner group (`GET /orgs/{org}/actions/runner-groups`, matching `GITHUB_RUNNER_GROUP`, or the group with `default: true` when it is empty) and refuses one with `allows_public_repositories: true`. For `GITHUB_RUNNER_SCOPE=repo` it reads `GET /repos/{owner}/{repo}` and refuses a public repository. -- The refusal has the error code `runner_group_public` (HTTP 409 from the plugin API, non-zero exit from `install.sh` and `actions/start.sh`). If the check cannot be completed (API error, missing permission) the runner does not start either (`runner_trust_unverifiable`). +- The refusal has the error code `runner_group_public` (HTTP 409 from the plugin API, also returned by the status call). If the check cannot be completed (API error, missing permission) the runner does not start either (`runner_trust_unverifiable`). - A group or repository that becomes public after registration stops the running runner on the next start or status call. - `GITHUB_RUNNER_SCOPE` must be `org` or `repo` (case-insensitive); any other value is refused as `runner_trust_unverifiable`, so a typo never skips the repository check. Organisation and repository names are validated before use. - A stop signals the runner and all of its child processes (`run.sh` does not forward signals to `Runner.Listener`). For a systemd runner the unit is stopped and also disabled, so a reboot does not bring it back into a public group; the next `start` re-checks the group and starts the runner again when it is private. diff --git a/free/github-runner/actions/logs.sh b/free/github-runner/actions/logs.sh deleted file mode 100755 index 0b6dd930a..000000000 --- a/free/github-runner/actions/logs.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -# Tail GitHub Actions runner logs - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$(dirname "$PLUGIN_DIR")")/shared" -source "${SHARED_DIR}/plugin-utils.sh" -source "${PLUGIN_DIR}/actions/trust.sh" - -LOG_DIR="${HOME}/.nself/logs/plugins/github-runner" -RUNNER_DIR="${HOME}/.nself/runners/github-runner" - -# systemd logs -if command -v journalctl &>/dev/null; then - svc_name="$(trust_service_name)" - if systemctl is-enabled --quiet "$svc_name" 2>/dev/null; then - plugin_info "Streaming systemd journal for $svc_name..." - journalctl -u "$svc_name" -n 50 -f - exit 0 - fi -fi - -# nohup log -LOG_FILE="${LOG_DIR}/runner.log" -if [[ -f "$LOG_FILE" ]]; then - plugin_info "Streaming $LOG_FILE..." - tail -n 50 -f "$LOG_FILE" -else - plugin_warn "No log file found at $LOG_FILE" - plugin_info "Try: nself plugin github-runner start" -fi diff --git a/free/github-runner/actions/start.sh b/free/github-runner/actions/start.sh deleted file mode 100755 index 621463dfb..000000000 --- a/free/github-runner/actions/start.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash -# Start the GitHub Actions runner - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$(dirname "$PLUGIN_DIR")")/shared" -source "${SHARED_DIR}/plugin-utils.sh" -source "${PLUGIN_DIR}/actions/trust.sh" - -RUNNER_DIR="${HOME}/.nself/runners/github-runner" -LOG_DIR="${HOME}/.nself/logs/plugins/github-runner" -PID_FILE="${LOG_DIR}/runner.pid" - -if [[ ! -f "${RUNNER_DIR}/run.sh" ]]; then - plugin_error "Runner not installed. Run: nself plugin install github-runner" - exit 1 -fi - -# Trusted-only guard: re-checked on every start. A group or repo that is now -# open to public repositories stops a running runner unless overridden. -trust_rc=0 -trust_check || trust_rc=$? -if [[ "$trust_rc" -ne 0 ]]; then - if [[ "$trust_rc" -eq 1 || "$trust_rc" -eq 4 ]]; then - trust_stop_runner "$PID_FILE" "$(trust_service_name)" \ - || plugin_error "The running runner could NOT be fully stopped (see the message above). Stop it manually." - fi - plugin_error "Runner not started (see the message above)." - exit "$trust_rc" -fi -host_warning=$(trust_deploy_host_warning) -[[ -z "$host_warning" ]] || plugin_warn "$host_warning" - -# Check if already running -if [[ -f "$PID_FILE" ]]; then - local_pid=$(cat "$PID_FILE") - if kill -0 "$local_pid" 2>/dev/null; then - plugin_warn "Runner already running (PID: $local_pid)" - exit 0 - fi -fi - -# Try systemd first -if command -v systemctl &>/dev/null; then - svc_name="$(trust_service_name)" - # `systemctl cat` also finds a unit the trust guard disabled; enable it again - # now that the guard has passed. - if systemctl cat "$svc_name" >/dev/null 2>&1; then - plugin_info "Starting systemd service..." - if [[ $EUID -eq 0 ]]; then - systemctl enable --now "$svc_name" - else - sudo systemctl enable --now "$svc_name" - fi - plugin_success "Runner started via systemd." - exit 0 - fi -fi - -# Fallback: nohup -mkdir -p "$LOG_DIR" -plugin_info "Starting runner (nohup)..." -( - cd "$RUNNER_DIR" - RUNNER_ALLOW_RUNASROOT=1 nohup ./run.sh >> "${LOG_DIR}/runner.log" 2>&1 & - echo $! > "$PID_FILE" -) -plugin_success "Runner started (PID: $(cat "$PID_FILE"))" -plugin_info "Logs: $LOG_DIR/runner.log" diff --git a/free/github-runner/actions/status.sh b/free/github-runner/actions/status.sh deleted file mode 100755 index e5b8fa8d7..000000000 --- a/free/github-runner/actions/status.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/bash -# Show GitHub Actions runner status - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$(dirname "$PLUGIN_DIR")")/shared" -source "${SHARED_DIR}/plugin-utils.sh" -source "${PLUGIN_DIR}/actions/trust.sh" - -RUNNER_DIR="${HOME}/.nself/runners/github-runner" -LOG_DIR="${HOME}/.nself/logs/plugins/github-runner" -PID_FILE="${LOG_DIR}/runner.pid" - -printf "\n=== GitHub Actions Runner Status ===\n\n" - -# ------------------------------------------------------------------ -# Trusted-only guard: re-run on every status call. A group or repo that is now -# open to public repositories stops a running runner unless overridden. -# ------------------------------------------------------------------ -printf "Trust:\n" -trust_rc=0 -trust_check || trust_rc=$? -case "$trust_rc" in - 0) printf " Check: passed%s\n" "${TRUST_GROUP:+ (group: $TRUST_GROUP)}" ;; - 1|4) - printf " Check: REFUSED (%s)\n" "$([[ "$trust_rc" -eq 1 ]] && echo runner_group_public || echo runner_group_mismatch)" - stop_rc=0 - stop_out=$(trust_stop_runner "$PID_FILE" "$(trust_service_name)" 2>&1) || stop_rc=$? - [[ -z "$stop_out" ]] || printf '%s\n' "$stop_out" | sed 's/^/ /' - [[ "$stop_rc" -eq 0 ]] || printf " Stop: FAILED, the runner may still be running (see the message above)\n" - ;; - *) printf " Check: could not be completed (see the message above)\n" ;; -esac -deploy_host_warning=$(trust_deploy_host_warning) -[[ -z "$deploy_host_warning" ]] || printf " deploy_host_warning: %s\n" "$deploy_host_warning" -printf "\n" - -# ------------------------------------------------------------------ -# Local process status -# ------------------------------------------------------------------ -printf "Local Process:\n" - -is_running=false - -# Check systemd -if command -v systemctl &>/dev/null; then - svc_name="$(trust_service_name)" - if systemctl is-active --quiet "$svc_name" 2>/dev/null; then - printf " Status: \033[32mRunning\033[0m (systemd: %s)\n" "$svc_name" - is_running=true - fi -fi - -# Check nohup PID -if [[ "$is_running" == "false" && -f "$PID_FILE" ]]; then - pid=$(cat "$PID_FILE") - if kill -0 "$pid" 2>/dev/null; then - printf " Status: \033[32mRunning\033[0m (PID: %s)\n" "$pid" - is_running=true - else - printf " Status: \033[31mStopped\033[0m (stale PID file)\n" - fi -fi - -if [[ "$is_running" == "false" ]]; then - printf " Status: \033[31mNot running\033[0m\n" -fi - -# ------------------------------------------------------------------ -# Runner config -# ------------------------------------------------------------------ -if [[ -f "${RUNNER_DIR}/.runner" ]]; then - printf "\nRunner Config:\n" - if command -v jq &>/dev/null; then - jq -r ' - " Name: " + .agentName, - " URL: " + .gitHubUrl, - " Labels: " + (.agentLabels | map(.name) | join(",")) - ' "${RUNNER_DIR}/.runner" 2>/dev/null || true - else - grep '"agentName"\|"gitHubUrl"' "${RUNNER_DIR}/.runner" | sed 's/.*: "\(.*\)".*/ \1/' || true - fi -fi - -# ------------------------------------------------------------------ -# GitHub API status (if PAT available) -# ------------------------------------------------------------------ -if [[ -n "${GITHUB_RUNNER_PAT:-}" && -n "${GITHUB_RUNNER_ORG:-}" ]]; then - printf "\nGitHub Registration:\n" - local_name="${GITHUB_RUNNER_NAME:-$(hostname)-nself}" - - result=$(curl -sSf \ - -H "Authorization: token ${GITHUB_RUNNER_PAT}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/orgs/${GITHUB_RUNNER_ORG}/actions/runners" 2>/dev/null || echo '{"runners":[]}') - - if command -v jq &>/dev/null; then - echo "$result" | jq -r --arg name "$local_name" ' - .runners[] | select(.name == $name) | - " Runner: " + .name + " (ID: " + (.id|tostring) + ")", - " Status: " + .status, - " Busy: " + (.busy|tostring), - " Labels: " + ([.labels[].name] | join(",")) - ' 2>/dev/null || printf " Not registered (name: %s)\n" "$local_name" - else - printf " (install jq for detailed GitHub status)\n" - fi -fi - -printf "\n" diff --git a/free/github-runner/actions/stop.sh b/free/github-runner/actions/stop.sh deleted file mode 100755 index d1ea85de5..000000000 --- a/free/github-runner/actions/stop.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash -# Stop the GitHub Actions runner - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$(dirname "$PLUGIN_DIR")")/shared" -source "${SHARED_DIR}/plugin-utils.sh" -source "${PLUGIN_DIR}/actions/trust.sh" - -LOG_DIR="${HOME}/.nself/logs/plugins/github-runner" -PID_FILE="${LOG_DIR}/runner.pid" -RUNNER_DIR="${HOME}/.nself/runners/github-runner" - -stopped=false -failed=false - -# Try systemd first -if command -v systemctl &>/dev/null; then - svc_name="$(trust_service_name)" - if systemctl is-active --quiet "$svc_name" 2>/dev/null; then - plugin_info "Stopping systemd service..." - if [[ $EUID -eq 0 ]]; then - systemctl stop "$svc_name" - else - sudo systemctl stop "$svc_name" - fi - plugin_success "Runner stopped (systemd)." - stopped=true - fi -fi - -# Kill nohup PID -if [[ -f "$PID_FILE" ]]; then - pid=$(cat "$PID_FILE") - if kill -0 "$pid" 2>/dev/null; then - plugin_info "Stopping runner process (PID: $pid)..." - if _trust_kill_tree "$pid"; then - plugin_success "Runner stopped." - stopped=true - else - plugin_error "Runner process did not stop." - failed=true - fi - fi - [[ "$failed" == "true" ]] || rm -f "$PID_FILE" -fi - -if [[ "$failed" == "true" ]]; then - exit 1 -fi - -if [[ "$stopped" == "false" ]]; then - plugin_warn "Runner was not running." -fi diff --git a/free/github-runner/actions/trust.sh b/free/github-runner/actions/trust.sh deleted file mode 100644 index 7f8684b00..000000000 --- a/free/github-runner/actions/trust.sh +++ /dev/null @@ -1,323 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Trusted-only guard for the GitHub Actions runner (Epic D23, ADR 0025 s2) -# ============================================================================= -# Sourced by install.sh, actions/start.sh and actions/status.sh. A persistent -# runner on the nSelf host must only run jobs from repositories the operator -# trusts, so before registration, on every start and in status the runner group -# (org scope) or repository (repo scope) is read through the GitHub API and one -# that is open to public repositories is refused with `runner_group_public`. -# -# Needs: curl, jq. The PAT goes to curl through a config on stdin, never argv. -# GITHUB_RUNNER_ALLOW_PUBLIC_REPOS=true accepts the risk (warned on every use). -# ============================================================================= - -TRUST_API_BASE="https://api.github.com" -TRUST_GROUP_PAGE_SIZE=100 -TRUST_MAX_PAGES=50 - -# trust_api_get URL: print the JSON body of an authenticated GET; fail if the -# call or the status is not a 200. Error text never contains the PAT. -trust_api_get() { - local url="$1" out status body - out=$(printf 'header = "Authorization: Bearer %s"\n' "${GITHUB_RUNNER_PAT}" \ - | curl -sS -K - -w '\n%{http_code}' \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "$url" 2>/dev/null) || return 1 - status="${out##*$'\n'}" - body="${out%$'\n'*}" - [[ "$status" == "200" ]] || return 1 - printf '%s' "$body" -} - -# trust_unverifiable MESSAGE: the check could not be completed. Fails closed. -trust_unverifiable() { - printf 'runner_trust_unverifiable: %s\n' "$1" >&2 - return 2 -} - -# trust_scope: print the normalised scope (org or repo, case-insensitive, -# default org); fail on anything else so no caller guesses a scope. -trust_scope() { - local s - s=$(printf '%s' "${GITHUB_RUNNER_SCOPE:-org}" | tr '[:upper:]' '[:lower:]') - [[ -n "$s" ]] || s=org - case "$s" in - org|repo) printf '%s' "$s" ;; - *) return 1 ;; - esac -} - -# trust_valid_name VALUE: GitHub owner and repo names only. -trust_valid_name() { [[ "$1" =~ ^[A-Za-z0-9._-]+$ ]]; } - -# trust_check: 0 = trusted (or override set), 1 = refused (runner_group_public), -# 2 = unverifiable (fail closed), 3 = group not found, 4 = the runner is -# registered in another group than the configured one (runner_group_mismatch). -# Sets TRUST_GROUP. TRUST_REGISTERING=true (install.sh, about to register with -# --replace) skips the registered-group check so a re-install can move a runner. -trust_check() { - local org="${GITHUB_RUNNER_ORG:-}" scope - local allow="${GITHUB_RUNNER_ALLOW_PUBLIC_REPOS:-false}" - TRUST_GROUP="" - if [[ -z "${GITHUB_RUNNER_PAT:-}" || -z "$org" ]]; then - trust_unverifiable "GITHUB_RUNNER_PAT and GITHUB_RUNNER_ORG must be set so the runner group can be checked" - return 2 - fi - command -v jq >/dev/null 2>&1 || { trust_unverifiable "jq is required for the runner group check"; return 2; } - scope=$(trust_scope) || { trust_unverifiable "GITHUB_RUNNER_SCOPE must be org or repo (got \"${GITHUB_RUNNER_SCOPE}\")"; return 2; } - trust_valid_name "$org" || { trust_unverifiable "GITHUB_RUNNER_ORG contains characters GitHub does not allow"; return 2; } - if [[ "$scope" == "repo" && -n "${GITHUB_RUNNER_REPO:-}" ]]; then - trust_valid_name "$GITHUB_RUNNER_REPO" || { trust_unverifiable "GITHUB_RUNNER_REPO contains characters GitHub does not allow"; return 2; } - fi - if [[ "$scope" == "repo" ]]; then - _trust_check_repo "$org" "${GITHUB_RUNNER_REPO:-}" "$allow" - else - _trust_check_group "$org" "${GITHUB_RUNNER_GROUP:-}" "$allow" - fi -} - -_trust_check_group() { - local org="$1" group="$2" allow="$3" page body found public count gid rest - for ((page = 1; page <= TRUST_MAX_PAGES; page++)); do - body=$(trust_api_get "${TRUST_API_BASE}/orgs/${org}/actions/runner-groups?per_page=${TRUST_GROUP_PAGE_SIZE}&page=${page}") \ - || { trust_unverifiable "GitHub API call for the runner groups failed (token needs the organisation \"Self-hosted runners\" permission)"; return 2; } - found=$(printf '%s' "$body" | jq -r --arg g "$group" ' - [.runner_groups[]? | select(if $g == "" then .default == true else (.name | ascii_downcase) == ($g | ascii_downcase) end)] - | first | if . == null then "" else "\(.name)\t\(.allows_public_repositories)\t\(.id)" end' 2>/dev/null) \ - || { trust_unverifiable "could not decode the runner groups response"; return 2; } - [[ -n "$found" ]] && break - count=$(printf '%s' "$body" | jq '.runner_groups | length' 2>/dev/null) || count=0 - [[ "${count:-0}" =~ ^[0-9]+$ && "${count:-0}" -lt "$TRUST_GROUP_PAGE_SIZE" ]] && break - done - if [[ -z "$found" ]]; then - printf 'runner_group_not_found: runner group "%s" not found in the organisation\n' "${group:-}" >&2 - return 3 - fi - TRUST_GROUP="${found%%$'\t'*}" - rest="${found#*$'\t'}" - public="${rest%%$'\t'*}" - gid="${rest##*$'\t'}" - case "$public" in - false) _trust_registered_group "$org" "$gid"; return $? ;; - true) ;; - *) trust_unverifiable "GitHub did not report whether runner group \"${TRUST_GROUP}\" allows public repositories"; return 2 ;; - esac - _trust_public "runner group \"${TRUST_GROUP}\" is open to public repositories" \ - "Restrict the group to private repositories in the organisation settings" "$allow" || return $? - _trust_registered_group "$org" "$gid" -} - -# _trust_has_runner URL NAME ID: 0 if the paged runner list at URL holds the -# runner, 1 if not, 2 if the list could not be read. With an ID it matches the -# id GitHub issued (the persisted registration); else NAME, case-insensitive. -_trust_has_runner() { - local url="$1" name="$2" id="$3" page body hit count - for ((page = 1; page <= TRUST_MAX_PAGES; page++)); do - body=$(trust_api_get "${url}?per_page=${TRUST_GROUP_PAGE_SIZE}&page=${page}") || return 2 - hit=$(printf '%s' "$body" | jq -r --arg n "$name" --arg id "$id" \ - '[.runners[]? | select(if $id != "" then (.id | tostring) == $id else (.name | ascii_downcase) == ($n | ascii_downcase) end)] | length' 2>/dev/null) || return 2 - [[ "${hit:-0}" =~ ^[0-9]+$ ]] || return 2 - [[ "$hit" -eq 0 ]] || return 0 - count=$(printf '%s' "$body" | jq '.runners | length' 2>/dev/null) || return 2 - [[ "${count:-0}" =~ ^[0-9]+$ && "${count:-0}" -lt "$TRUST_GROUP_PAGE_SIZE" ]] && return 1 - done - return 2 -} - -# _trust_persisted_runner: print "IDNAME" of the registration the runner -# binary persisted in RUNNER_DIR/.runner (written with a UTF-8 BOM). Prints -# nothing and succeeds when the file is absent (not registered); fails when it -# exists but names no runner, so the caller fails closed. -_trust_persisted_runner() { - local f="${RUNNER_DIR:-${HOME}/.nself/runners/github-runner}/.runner" - [[ -e "$f" ]] || return 0 - LC_ALL=C sed $'1s/^\xEF\xBB\xBF//' "$f" 2>/dev/null \ - | jq -er 'select((.agentId // "") != "" or (.agentName // "") != "") | "\(.agentId // "")\t\(.agentName // "")"' -} - -# _trust_runner_in_other_group ORG GID ID NAME: 0 = the runner is in the -# organisation but not in group GID (mismatch), 1 = fine (in the group, or -# unknown to GitHub), 2 = a list could not be read. -_trust_runner_in_other_group() { - local org="$1" gid="$2" id="$3" name="$4" rc - rc=0; _trust_has_runner "${TRUST_API_BASE}/orgs/${org}/actions/runner-groups/${gid}/runners" "$name" "$id" || rc=$? - case "$rc" in 0) return 1 ;; 1) ;; *) return 2 ;; esac - rc=0; _trust_has_runner "${TRUST_API_BASE}/orgs/${org}/actions/runners" "$name" "$id" || rc=$? - case "$rc" in 0) return 0 ;; 1) return 1 ;; *) return 2 ;; esac -} - -# _trust_registered_group ORG GROUP_ID: the configured group passed, so the -# runner must be in it too. A runner registered in another group (the config -# changed without re-registering) is refused (4); one that is not registered at -# all passes. Both the configured name and the registration persisted in -# RUNNER_DIR/.runner are checked, so renaming the runner (GITHUB_RUNNER_NAME or -# the hostname) does not hide the installed one. Skipped while -# TRUST_REGISTERING=true. -_trust_registered_group() { - local org="$1" gid="$2" name="${GITHUB_RUNNER_NAME:-$(hostname)-nself}" persisted pid pname rc ref - [[ "${TRUST_REGISTERING:-false}" != "true" ]] || return 0 - [[ "$gid" =~ ^[0-9]+$ ]] || { trust_unverifiable "GitHub did not report the id of runner group \"${TRUST_GROUP}\""; return 2; } - persisted=$(_trust_persisted_runner) \ - || { trust_unverifiable "the installed runner registration cannot be read, so its runner group cannot be verified"; return 2; } - # each check: ID and NAME, in the order configured name first, then installed - local -a checks=("|${name}") - [[ -z "$persisted" ]] || { pid="${persisted%%$'\t'*}"; pname="${persisted#*$'\t'}"; checks+=("${pid}|${pname}"); } - for ref in "${checks[@]}"; do - rc=0; _trust_runner_in_other_group "$org" "$gid" "${ref%%|*}" "${ref#*|}" || rc=$? - case "$rc" in - 0) - printf 'runner_group_mismatch: runner "%s" is registered in a different runner group from the configured "%s", so the check above does not cover it. Re-register the runner (install again) so it joins "%s", or set GITHUB_RUNNER_GROUP to the group it is in\n' \ - "${ref#*|}" "$TRUST_GROUP" "$TRUST_GROUP" >&2 - return 4 ;; - 1) ;; - *) trust_unverifiable "GitHub API call for the runners of group \"${TRUST_GROUP}\" failed"; return 2 ;; - esac - done - return 0 -} - -_trust_check_repo() { - local org="$1" repo="$2" allow="$3" body private - if [[ -z "$repo" ]]; then - trust_unverifiable "GITHUB_RUNNER_REPO is required for repo scope" - return 2 - fi - body=$(trust_api_get "${TRUST_API_BASE}/repos/${org}/${repo}") \ - || { trust_unverifiable "GitHub API call for the repository failed"; return 2; } - private=$(printf '%s' "$body" | jq -r 'if .private == null then "unknown" else (.private | tostring) end' 2>/dev/null) \ - || { trust_unverifiable "could not decode the repository response"; return 2; } - case "$private" in - true) return 0 ;; - false) _trust_public "repository ${org}/${repo} is public" "Use a private repository" "$allow" ;; - *) trust_unverifiable "GitHub did not report whether the repository is private"; return 2 ;; - esac -} - -# _trust_public WHAT FIX ALLOW: refuse (1) unless the override is set (0, warn). -_trust_public() { - local what="$1" fix="$2" allow="$3" - if [[ "$allow" == "true" ]]; then - printf 'WARNING: %s; GITHUB_RUNNER_ALLOW_PUBLIC_REPOS=true lets fork pull requests run on this host\n' "$what" >&2 - return 0 - fi - printf 'runner_group_public: %s, so fork pull requests could run on this host. %s, or set GITHUB_RUNNER_ALLOW_PUBLIC_REPOS=true to accept the risk\n' \ - "$what" "$fix" >&2 - return 1 -} - -# trust_deploy_host_warning: print the warning when ENV is prod, production or -# staging, else nothing. -trust_deploy_host_warning() { - case "$(printf '%s' "${ENV:-}" | tr '[:upper:]' '[:lower:]')" in - prod|production|staging) - printf '%s\n' "this runner shares a host with a deployed nSelf stack: it is a persistent, non-isolated runner. Use it for trusted private repositories only; never for fork pull requests" - ;; - esac -} - -# trust_service_name: the systemd unit of the installed runner. svc.sh writes -# the exact name to RUNNER_DIR/.service (it differs for repo scope and long -# names); fall back to the org-scope pattern. -trust_service_name() { - local f="${RUNNER_DIR:-}/.service" - if [[ -n "${RUNNER_DIR:-}" && -s "$f" ]]; then - tr -d '[:space:]' < "$f" - return - fi - printf 'actions.runner.%s.%s' "${GITHUB_RUNNER_ORG:-nself-org}" "${GITHUB_RUNNER_NAME:-$(hostname)-nself}" -} - -# TRUST_STOP_GRACE: seconds a stop waits after TERM before it escalates to KILL -# (and again after KILL before it reports survivors). -TRUST_STOP_GRACE="${TRUST_STOP_GRACE:-5}" - -# _trust_tree_pids PID: print PID and all its descendants, one per line, -# descendants first. run.sh starts Runner.Listener and does not forward -# signals, so killing only the wrapper leaves a listener that still takes jobs. -_trust_tree_pids() { - local pid="$1" child - for child in $(pgrep -P "$pid" 2>/dev/null || true); do - _trust_tree_pids "$child" - done - printf '%s\n' "$pid" -} - -# _trust_pid_alive PID: running (a zombie awaiting its parent counts as gone). -_trust_pid_alive() { - local state - state=$(ps -o stat= -p "$1" 2>/dev/null | tr -d ' ') || return 1 - [[ -n "$state" && "$state" != Z* ]] -} - -# _trust_live_pids PID...: print, space separated, those PIDs still running. -_trust_live_pids() { - local p out="" - for p in "$@"; do - ! _trust_pid_alive "$p" || out="${out:+$out }$p" - done - printf '%s' "$out" -} - -# _trust_kill_tree PID: stop the process and all its descendants. The tree is -# listed once, before any signal, so a child that outlives a dead wrapper (and -# is reparented) is still signalled: TERM to all, a grace wait, then KILL to -# the survivors of that same list. Returns 1 if a process is still running. -_trust_kill_tree() { - local pid="$1" live waited p - live=$(_trust_tree_pids "$pid" | tr '\n' ' ') - for p in $live; do kill -TERM "$p" 2>/dev/null || true; done - for ((waited = 0; waited < TRUST_STOP_GRACE * 5; waited++)); do - live=$(_trust_live_pids $live) - [[ -n "$live" ]] || return 0 - sleep 0.2 - done - for p in $live; do kill -KILL "$p" 2>/dev/null || true; done - for ((waited = 0; waited < TRUST_STOP_GRACE * 5; waited++)); do - live=$(_trust_live_pids $live) - [[ -n "$live" ]] || return 0 - sleep 0.2 - done - printf 'runner process(es) still running after KILL: %s\n' "$live" >&2 - return 1 -} - -# trust_stop_runner PID_FILE SERVICE: stop a running runner (systemd service or -# nohup process tree). The systemd unit is also disabled so a reboot does not -# bring the runner back into a public group; start.sh enables and starts it -# again after the guard passes. Prints what it stopped; nothing when nothing was -# running. Returns non-zero (and says why on stderr) if the service could not -# be stopped or disabled, or a process survived; it still tries every step. -trust_stop_runner() { - local pid_file="$1" svc="$2" pid sc="" rc=0 - if command -v systemctl >/dev/null 2>&1 && { systemctl is-active --quiet "$svc" 2>/dev/null || systemctl is-enabled --quiet "$svc" 2>/dev/null; }; then - [[ $EUID -eq 0 ]] || sc="sudo" - if systemctl is-active --quiet "$svc" 2>/dev/null; then - if $sc systemctl stop "$svc"; then - printf 'stopped systemd service %s\n' "$svc" - else - printf 'could not stop systemd service %s\n' "$svc" >&2 - rc=1 - fi - fi - if $sc systemctl disable "$svc" >/dev/null 2>&1; then - printf 'disabled systemd service %s (no restart at boot while the group is public)\n' "$svc" - else - printf 'could not disable systemd service %s\n' "$svc" >&2 - rc=1 - fi - fi - if [[ -f "$pid_file" ]]; then - pid=$(cat "$pid_file") - if kill -0 "$pid" 2>/dev/null; then - if _trust_kill_tree "$pid"; then - printf 'stopped runner (PID %s and its children)\n' "$pid" - else - printf 'could not stop runner (PID %s and its children)\n' "$pid" >&2 - rc=1 - fi - fi - fi - return "$rc" -} diff --git a/free/github-runner/actions/update.sh b/free/github-runner/actions/update.sh deleted file mode 100755 index 298923a62..000000000 --- a/free/github-runner/actions/update.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -# Update GitHub Actions runner to the latest version - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$(dirname "$PLUGIN_DIR")")/shared" -source "${SHARED_DIR}/plugin-utils.sh" - -RUNNER_DIR="${HOME}/.nself/runners/github-runner" - -if [[ ! -f "${RUNNER_DIR}/run.sh" ]]; then - plugin_error "Runner not installed. Run: nself plugin install github-runner" - exit 1 -fi - -# Get current version -current_ver="(unknown)" -if [[ -f "${RUNNER_DIR}/bin/Runner.Listener" ]]; then - current_ver=$("${RUNNER_DIR}/bin/Runner.Listener" --version 2>/dev/null | head -1 || echo "(unknown)") -fi - -plugin_info "Current version: $current_ver" -plugin_info "Stopping runner before update..." - -# Stop -"${PLUGIN_DIR}/actions/stop.sh" 2>/dev/null || true - -# Re-run installer (skip configure since .credentials already exist) -SKIP_CONFIGURE=true "${PLUGIN_DIR}/install.sh" - -plugin_success "Runner update complete." diff --git a/free/github-runner/install.sh b/free/github-runner/install.sh deleted file mode 100755 index 75521148c..000000000 --- a/free/github-runner/install.sh +++ /dev/null @@ -1,285 +0,0 @@ -#!/bin/bash -# ============================================================================= -# GitHub Actions Runner Plugin Installer -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$(dirname "$PLUGIN_DIR")")/shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" -source "${PLUGIN_DIR}/actions/trust.sh" - -# ============================================================================= -# Configuration -# ============================================================================= - -RUNNER_DIR="${HOME}/.nself/runners/github-runner" -LOG_DIR="${HOME}/.nself/logs/plugins/github-runner" -PID_FILE="${LOG_DIR}/runner.pid" - -# ============================================================================= -# Helpers -# ============================================================================= - -detect_arch() { - local arch - arch=$(uname -m) - case "$arch" in - x86_64) echo "x64" ;; - aarch64|arm64) echo "arm64" ;; - armv7l) echo "arm" ;; - *) - plugin_error "Unsupported architecture: $arch" - exit 1 - ;; - esac -} - -detect_os() { - local os - os=$(uname -s | tr '[:upper:]' '[:lower:]') - case "$os" in - linux) echo "linux" ;; - darwin) echo "osx" ;; - *) - plugin_error "Unsupported OS: $os" - exit 1 - ;; - esac -} - -get_latest_runner_version() { - local version - version=$(curl -sSf \ - -H "Authorization: token ${GITHUB_RUNNER_PAT}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/actions/runner/releases/latest" \ - | grep '"tag_name"' | head -1 | sed 's/.*"v\([^"]*\)".*/\1/') - - if [[ -z "$version" ]]; then - plugin_warn "Could not detect latest runner version, using 2.321.0" - echo "2.321.0" - else - echo "$version" - fi -} - -get_registration_token() { - local scope - scope=$(trust_scope) || { plugin_error "GITHUB_RUNNER_SCOPE must be org or repo"; exit 3; } - local token - - if [[ "$scope" == "org" ]]; then - token=$(curl -sSf -X POST \ - -H "Authorization: token ${GITHUB_RUNNER_PAT}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/orgs/${GITHUB_RUNNER_ORG}/actions/runners/registration-token" \ - | grep '"token"' | head -1 | sed 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') - - if [[ -z "$token" || "$token" == "null" ]]; then - plugin_error "Failed to get registration token. Verify GITHUB_RUNNER_PAT has 'admin:org' scope." - exit 1 - fi - echo "$token" - else - local repo="${GITHUB_RUNNER_REPO:?GITHUB_RUNNER_REPO required for repo-scope runner}" - token=$(curl -sSf -X POST \ - -H "Authorization: token ${GITHUB_RUNNER_PAT}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${GITHUB_RUNNER_ORG}/${repo}/actions/runners/registration-token" \ - | grep '"token"' | head -1 | sed 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') - - if [[ -z "$token" || "$token" == "null" ]]; then - plugin_error "Failed to get registration token for repo ${GITHUB_RUNNER_ORG}/${repo}." - exit 1 - fi - echo "$token" - fi -} - -config_url() { - local scope - scope=$(trust_scope) || { plugin_error "GITHUB_RUNNER_SCOPE must be org or repo"; exit 3; } - if [[ "$scope" == "org" ]]; then - echo "https://github.com/${GITHUB_RUNNER_ORG}" - else - echo "https://github.com/${GITHUB_RUNNER_ORG}/${GITHUB_RUNNER_REPO}" - fi -} - -# ============================================================================= -# Installation -# ============================================================================= - -install_github_runner() { - plugin_info "Installing GitHub Actions Runner plugin..." - - # ------------------------------------------------------------------ - # 1. Check required env vars - # ------------------------------------------------------------------ - if ! plugin_check_env "github-runner" "GITHUB_RUNNER_PAT" "GITHUB_RUNNER_ORG"; then - plugin_error "Required env vars missing. Set them in your .env file:" - printf " GITHUB_RUNNER_PAT=\n" - printf " GITHUB_RUNNER_ORG=\n" - exit 1 - fi - - # ------------------------------------------------------------------ - # 2. Verify PAT has required scope - # ------------------------------------------------------------------ - plugin_info "Verifying GitHub PAT scope..." - local scope_header - scope_header=$(curl -sI \ - -H "Authorization: token ${GITHUB_RUNNER_PAT}" \ - "https://api.github.com/user" \ - | { grep -i "x-oauth-scopes:" || true; } | tr -d '\r' | sed 's/.*: //') - - if [[ -z "$scope_header" ]]; then - plugin_warn "Could not read PAT scopes. Continuing — install will fail if PAT lacks 'admin:org'." - elif [[ "$scope_header" != *"admin:org"* && "$scope_header" != *"repo"* ]]; then - plugin_error "PAT is missing required scope." - printf " Current scopes: %s\n" "$scope_header" - printf " Required: 'admin:org' (for org-level runner) or 'repo' (for repo-level)\n" - printf " Create a new PAT at: https://github.com/settings/tokens\n" - exit 1 - else - plugin_success "PAT scope verified: $scope_header" - fi - - # ------------------------------------------------------------------ - # 2b. Trusted-only guard: refuse a runner group (or repo) open to public - # repositories before anything is downloaded or registered. - # ------------------------------------------------------------------ - plugin_info "Checking the runner group is trusted-only..." - local trust_rc=0 - TRUST_REGISTERING=true trust_check || trust_rc=$? # registering with --replace moves a runner, so it is not checked for its old group - if [[ "$trust_rc" -ne 0 ]]; then - plugin_error "Refusing to install the runner (see the message above)." - exit "$trust_rc" - fi - plugin_success "Runner group check passed${TRUST_GROUP:+: $TRUST_GROUP}" - local host_warning - host_warning=$(trust_deploy_host_warning) - [[ -z "$host_warning" ]] || plugin_warn "$host_warning" - - # ------------------------------------------------------------------ - # 3. Create directories - # ------------------------------------------------------------------ - mkdir -p "$RUNNER_DIR" "$LOG_DIR" - - # ------------------------------------------------------------------ - # 4. Download runner binary - # ------------------------------------------------------------------ - local os arch version - os=$(detect_os) - arch=$(detect_arch) - version="${GITHUB_RUNNER_VERSION:-$(get_latest_runner_version)}" - - local runner_pkg="actions-runner-${os}-${arch}-${version}.tar.gz" - local runner_url="https://github.com/actions/runner/releases/download/v${version}/${runner_pkg}" - - if [[ -f "${RUNNER_DIR}/run.sh" ]]; then - plugin_info "Runner binary already present, skipping download." - else - plugin_info "Downloading GitHub Actions runner v${version} (${os}/${arch})..." - curl -sSfL "$runner_url" -o "/tmp/${runner_pkg}" - - plugin_info "Extracting runner..." - tar xzf "/tmp/${runner_pkg}" -C "$RUNNER_DIR" - rm -f "/tmp/${runner_pkg}" - plugin_success "Runner downloaded and extracted." - fi - - # ------------------------------------------------------------------ - # 5. Configure runner (skipped during update if credentials exist) - # ------------------------------------------------------------------ - if [[ "${SKIP_CONFIGURE:-false}" == "true" && -f "${RUNNER_DIR}/.credentials" ]]; then - plugin_info "Skipping configuration (SKIP_CONFIGURE=true, existing credentials found)." - else - plugin_info "Configuring runner..." - local reg_token - reg_token=$(get_registration_token) - - local runner_name="${GITHUB_RUNNER_NAME:-$(hostname)-nself}" - local runner_labels="${GITHUB_RUNNER_LABELS:-self-hosted,linux,x64,ubuntu-latest}" - local runner_config_url - runner_config_url=$(config_url) - - local config_args=() - config_args+=(--url "$runner_config_url") - config_args+=(--token "$reg_token") - config_args+=(--name "$runner_name") - config_args+=(--labels "$runner_labels") - if [[ -n "${GITHUB_RUNNER_GROUP:-}" ]]; then - config_args+=(--runnergroup "$GITHUB_RUNNER_GROUP") - fi - config_args+=(--unattended --replace) - - ( - cd "$RUNNER_DIR" - # RUNNER_ALLOW_RUNASROOT=1 is required when nself runs as root (e.g. on Hetzner VPS) - RUNNER_ALLOW_RUNASROOT=1 ./config.sh "${config_args[@]}" - ) - plugin_success "Runner configured: $runner_name" - plugin_success "Labels: $runner_labels" - fi - - # ------------------------------------------------------------------ - # 6. Install as service or start via nohup - # ------------------------------------------------------------------ - _start_runner - - plugin_success "GitHub Actions Runner plugin installed successfully!" - printf "\n" - printf "Runner: %s\n" "$runner_name" - printf "Org: https://github.com/%s\n" "${GITHUB_RUNNER_ORG}" - printf "Labels: %s\n" "$runner_labels" - printf "\n" - printf "Manage with:\n" - printf " nself plugin github-runner status\n" - printf " nself plugin github-runner logs\n" - printf " nself plugin github-runner stop\n" - printf " nself plugin github-runner start\n" - printf "\n" - printf "View runners in GitHub:\n" - printf " https://github.com/organizations/%s/settings/actions/runners\n" "${GITHUB_RUNNER_ORG}" - printf "\n" -} - -# Start the runner (systemd if available, else nohup) -_start_runner() { - if command -v systemctl &>/dev/null && systemctl is-system-running --quiet 2>/dev/null; then - plugin_info "Installing systemd service..." - ( - cd "$RUNNER_DIR" - if [[ $EUID -eq 0 ]]; then - ./svc.sh install - ./svc.sh start - else - sudo ./svc.sh install - sudo ./svc.sh start - fi - ) && { - plugin_success "Runner installed as systemd service." - return 0 - } || { - plugin_warn "systemd install failed, falling back to nohup." - } - fi - - # Fallback: nohup - plugin_info "Starting runner (nohup)..." - ( - cd "$RUNNER_DIR" - # RUNNER_ALLOW_RUNASROOT=1 is required when nself runs as root - RUNNER_ALLOW_RUNASROOT=1 nohup ./run.sh >> "${LOG_DIR}/runner.log" 2>&1 & - echo $! > "$PID_FILE" - ) - plugin_success "Runner started (PID: $(cat "$PID_FILE"))" -} - -# Run installation -install_github_runner diff --git a/free/github-runner/tests/github-runner.test.sh b/free/github-runner/tests/github-runner.test.sh deleted file mode 100644 index e99589284..000000000 --- a/free/github-runner/tests/github-runner.test.sh +++ /dev/null @@ -1,352 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Tests for GitHub Actions Runner plugin -# ============================================================================= -# Run: bash tests/github-runner.test.sh -# -# Tests validate script structure and offline logic only. -# Registration/download tests require live GitHub credentials. - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PASS=0 -FAIL=0 - -pass() { PASS=$((PASS + 1)); printf " ✓ %s\n" "$1"; } -fail() { FAIL=$((FAIL + 1)); printf " ✗ %s\n" "$1"; } - -# ============================================================================= -# 1. File structure tests -# ============================================================================= -echo "File structure" - -[[ -f "$PLUGIN_DIR/plugin.json" ]] && pass "plugin.json exists" || fail "plugin.json missing" -[[ -f "$PLUGIN_DIR/install.sh" ]] && pass "install.sh exists" || fail "install.sh missing" -[[ -f "$PLUGIN_DIR/uninstall.sh" ]] && pass "uninstall.sh exists" || fail "uninstall.sh missing" -[[ -f "$PLUGIN_DIR/README.md" ]] && pass "README.md exists" || fail "README.md missing" - -for action in start stop status logs update; do - [[ -f "$PLUGIN_DIR/actions/${action}.sh" ]] && pass "actions/${action}.sh exists" || fail "actions/${action}.sh missing" -done - -# ============================================================================= -# 2. plugin.json validation -# ============================================================================= -echo "" -echo "plugin.json" - -name=$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['name'])") -[[ "$name" == "github-runner" ]] && pass "name is 'github-runner'" || fail "name is '$name'" - -license=$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['license_spdx'])") -[[ "$license" == "MIT" ]] && pass "license is MIT" || fail "license is '$license'" - -req_env=$(python3 -c "import json; d=json.load(open('$PLUGIN_DIR/plugin.json')); print(len([e for e in d['envVars'] if e.get('required')]))") -[[ "$req_env" -ge 2 ]] && pass "has $req_env required env vars" || fail "expected >=2 required env vars, got $req_env" - -# ============================================================================= -# 3. Shell script syntax validation -# ============================================================================= -echo "" -echo "Shell syntax (bash -n)" - -for script in "$PLUGIN_DIR/install.sh" "$PLUGIN_DIR/uninstall.sh" "$PLUGIN_DIR"/actions/*.sh; do - if bash -n "$script" 2>/dev/null; then - pass "$(basename "$script") — valid syntax" - else - fail "$(basename "$script") — syntax error" - fi -done - -# ============================================================================= -# 4. Architecture detection (offline) -# ============================================================================= -echo "" -echo "Architecture detection" - -# Source install.sh functions in a subshell to test detect_arch/detect_os -# We can't source directly because install.sh calls install_github_runner at the end. -# Instead, test that the functions exist by grepping. -grep -q 'detect_arch()' "$PLUGIN_DIR/install.sh" && pass "detect_arch() defined" || fail "detect_arch() missing" -grep -q 'detect_os()' "$PLUGIN_DIR/install.sh" && pass "detect_os() defined" || fail "detect_os() missing" -grep -q 'get_registration_token()' "$PLUGIN_DIR/install.sh" && pass "get_registration_token() defined" || fail "missing" -grep -q 'get_latest_runner_version()' "$PLUGIN_DIR/install.sh" && pass "get_latest_runner_version() defined" || fail "missing" - -# ============================================================================= -# 5. Action scripts check required vars -# ============================================================================= -echo "" -echo "Action scripts guard env vars" - -grep -q 'RUNNER_DIR' "$PLUGIN_DIR/actions/start.sh" && pass "start.sh references RUNNER_DIR" || fail "start.sh missing RUNNER_DIR" -grep -q 'RUNNER_DIR' "$PLUGIN_DIR/actions/stop.sh" && pass "stop.sh references RUNNER_DIR" || fail "stop.sh missing RUNNER_DIR" -grep -q 'RUNNER_DIR' "$PLUGIN_DIR/actions/status.sh" && pass "status.sh references RUNNER_DIR" || fail "status.sh missing RUNNER_DIR" -grep -q 'LOG_DIR' "$PLUGIN_DIR/actions/logs.sh" && pass "logs.sh references LOG_DIR" || fail "logs.sh missing LOG_DIR" - -# ============================================================================= -# 6. Trusted-only guard (stubbed curl, no network) -# ============================================================================= -echo "" -echo "Trusted-only guard" - -command -v jq >/dev/null 2>&1 || { echo "jq is required for the guard tests"; exit 1; } -: "${TMPDIR:?TMPDIR must be set}" -T=$(mktemp -d "${TMPDIR%/}/github-runner-test.XXXXXX") -trap 'rm -rf "$T"' EXIT -FAKE_PAT="fake-pat-0123456789-not-a-token" -mkdir -p "$T/bin" "$T/api" "$T/home/.nself/runners/github-runner" "$T/home/.nself/logs/plugins/github-runner" - -# Stub curl: serves $T/api/{groups-N,repo,user}.json, logs argv, appends the HTTP -# status after the body when asked with -w. Reads stdin for `-K -`. -cat > "$T/bin/curl" <<'STUB' -#!/bin/bash -printf '%s\n' "$*" >> "$STUB_DIR/argv.log" -args=("$@"); url=""; want_status=false; head_only=false -for a in "${args[@]}"; do - [[ "$a" == http* ]] && url="$a" - [[ "$a" == "-w" ]] && want_status=true - [[ "$a" == "-sI" ]] && head_only=true -done -[[ " ${args[*]} " == *" -K "* ]] && cat > /dev/null -if $head_only; then - # A fine-grained token gets no x-oauth-scopes header. - if [[ -f "$STUB_DIR/no-scopes" ]]; then printf 'HTTP/2 200\r\n'; else printf 'HTTP/2 200\r\nx-oauth-scopes: admin:org\r\n'; fi - exit 0 -fi -case "$url" in - # runner lists (default: no runner registered): a group's runners, then the org's - */runner-groups/*/runners*) f="$STUB_DIR/api/group-runners.json"; [[ -f "$f" ]] || { code=200; echo '{"runners":[]}'; $want_status && printf '\n%s' "$code"; exit 0; } ;; - */actions/runners\?*) f="$STUB_DIR/api/org-runners.json"; [[ -f "$f" ]] || { code=200; echo '{"runners":[]}'; $want_status && printf '\n%s' "$code"; exit 0; } ;; - *runner-groups*page=*) f="$STUB_DIR/api/groups-${url##*page=}.json" ;; - */repos/*/actions/runners/registration-token) f="$STUB_DIR/api/token.json" ;; - */orgs/*/actions/runners/registration-token) f="$STUB_DIR/api/token.json" ;; - */repos/*) f="$STUB_DIR/api/repo.json" ;; - *) f="$STUB_DIR/api/none.json" ;; -esac -if [[ -f "$f" ]]; then code=200; cat "$f"; else code=404; fi -$want_status && printf '\n%s' "$code" -exit 0 -STUB -chmod +x "$T/bin/curl" -export STUB_DIR="$T" PATH="$T/bin:$PATH" - -reset_api() { rm -f "$T"/api/* "$T/argv.log"; : > "$T/argv.log"; } -group_json() { # name default public [id] - printf '{"id":%s,"name":"%s","default":%s,"allows_public_repositories":%s}' "${4:-1}" "$1" "$2" "$3" -} -export GITHUB_RUNNER_PAT="$FAKE_PAT" GITHUB_RUNNER_ORG="acme" HOME="$T/home" -unset GITHUB_RUNNER_GROUP GITHUB_RUNNER_SCOPE GITHUB_RUNNER_REPO GITHUB_RUNNER_ALLOW_PUBLIC_REPOS ENV 2>/dev/null || true -source "$PLUGIN_DIR/actions/trust.sh" - -run_check() { local rc=0; ERR=$(trust_check 2>&1 >/dev/null) || rc=$?; RC=$rc; } - -reset_api -printf '{"runner_groups":[%s]}' "$(group_json Default true true)" > "$T/api/groups-1.json" -run_check -[[ "$RC" == 1 && "$ERR" == *runner_group_public* ]] && pass "public group is refused with runner_group_public" || fail "public group: rc=$RC err=$ERR" -[[ "$ERR" != *"$FAKE_PAT"* ]] && pass "refusal message does not print the PAT" || fail "PAT leaked in message" -! grep -q "$FAKE_PAT" "$T/argv.log" && pass "PAT never appears on curl argv" || fail "PAT on curl argv" - -GITHUB_RUNNER_ALLOW_PUBLIC_REPOS=true run_check -[[ "$RC" == 0 && "$ERR" == *WARNING* ]] && pass "override proceeds with a warning" || fail "override: rc=$RC err=$ERR" - -printf '{"runner_groups":[%s]}' "$(group_json Default true false)" > "$T/api/groups-1.json" -run_check -[[ "$RC" == 0 ]] && pass "private-only group proceeds" || fail "private group: rc=$RC err=$ERR" - -# Empty group name resolves to the default group on page 2. -reset_api -jq -n '{runner_groups: [range(0;100) | {id: ., name: ("g\(.)"), default: false, allows_public_repositories: false}]}' > "$T/api/groups-1.json" -printf '{"runner_groups":[%s]}' "$(group_json Everyone true true)" > "$T/api/groups-2.json" -run_check -[[ "$RC" == 1 && "$ERR" == *Everyone* ]] && pass "empty group resolves the default group across two pages" || fail "pagination: rc=$RC err=$ERR" - -# Named group, case-insensitive. -reset_api -printf '{"runner_groups":[%s,%s]}' "$(group_json Default true true)" "$(group_json CI false false)" > "$T/api/groups-1.json" -GITHUB_RUNNER_GROUP=ci run_check -[[ "$RC" == 0 ]] && pass "named private group proceeds while the default is public" || fail "named group: rc=$RC err=$ERR" - -# A runner registered in another group than the configured one is refused (D-1180). -reset_api -printf '{"runner_groups":[%s,%s]}' "$(group_json Default true true)" "$(group_json CI false false 2)" > "$T/api/groups-1.json" -printf '{"runners":[{"name":"n1"}]}' > "$T/api/org-runners.json" -GITHUB_RUNNER_GROUP=CI GITHUB_RUNNER_NAME=n1 run_check -[[ "$RC" == 4 && "$ERR" == *runner_group_mismatch* ]] && pass "runner registered in another group is refused with runner_group_mismatch" || fail "mismatch: rc=$RC err=$ERR" -GITHUB_RUNNER_GROUP=CI GITHUB_RUNNER_NAME=n1 TRUST_REGISTERING=true run_check -[[ "$RC" == 0 ]] && pass "install (TRUST_REGISTERING) may move a runner into the configured group" || fail "registering: rc=$RC err=$ERR" -printf '{"runners":[{"name":"N1"}]}' > "$T/api/group-runners.json" -GITHUB_RUNNER_GROUP=CI GITHUB_RUNNER_NAME=n1 run_check -[[ "$RC" == 0 ]] && pass "runner registered in the configured group proceeds" || fail "in group: rc=$RC err=$ERR" -rm -f "$T/api/group-runners.json" "$T/api/org-runners.json" -GITHUB_RUNNER_GROUP=CI GITHUB_RUNNER_NAME=n1 run_check -[[ "$RC" == 0 ]] && pass "runner not registered anywhere proceeds" || fail "unregistered: rc=$RC err=$ERR" - -# The installed registration (RUNNER_DIR/.runner) is checked too, so renaming the -# runner cannot hide it (D-1180). Written with a UTF-8 BOM like the runner does. -reset_api -printf '{"runner_groups":[%s,%s]}' "$(group_json Default true true)" "$(group_json CI false false 2)" > "$T/api/groups-1.json" -printf '{"runners":[{"id":77,"name":"old"}]}' > "$T/api/org-runners.json" -mkdir -p "$T/home/.nself/runners/github-runner" -printf '\xef\xbb\xbf{"agentId": 77, "agentName": "old", "poolId": 1}' > "$T/home/.nself/runners/github-runner/.runner" -GITHUB_RUNNER_GROUP=CI GITHUB_RUNNER_NAME=n1 run_check -[[ "$RC" == 4 && "$ERR" == *runner_group_mismatch* ]] && pass "renamed runner whose installed registration is in another group is refused" || fail "persisted mismatch: rc=$RC err=$ERR" -GITHUB_RUNNER_GROUP=CI GITHUB_RUNNER_NAME=n1 TRUST_REGISTERING=true run_check -[[ "$RC" == 0 ]] && pass "install (TRUST_REGISTERING) skips the installed registration" || fail "persisted registering: rc=$RC err=$ERR" -printf '{"runners":[{"id":77,"name":"old"}]}' > "$T/api/group-runners.json" -GITHUB_RUNNER_GROUP=CI GITHUB_RUNNER_NAME=n1 run_check -[[ "$RC" == 0 ]] && pass "installed registration in the configured group proceeds" || fail "persisted in group: rc=$RC err=$ERR" -rm -f "$T/api/group-runners.json" "$T/api/org-runners.json" -GITHUB_RUNNER_GROUP=CI GITHUB_RUNNER_NAME=n1 run_check -[[ "$RC" == 0 ]] && pass "installed registration unknown to GitHub proceeds" || fail "persisted unknown: rc=$RC err=$ERR" -echo '{not json' > "$T/home/.nself/runners/github-runner/.runner" -GITHUB_RUNNER_GROUP=CI GITHUB_RUNNER_NAME=n1 run_check -[[ "$RC" == 2 && "$ERR" == *runner_trust_unverifiable* ]] && pass "unreadable installed registration fails closed" || fail "persisted corrupt: rc=$RC err=$ERR" -rm -f "$T/home/.nself/runners/github-runner/.runner" - -# Missing flag and API failure fail closed. -reset_api -printf '{"runner_groups":[{"name":"Default","default":true}]}' > "$T/api/groups-1.json" -run_check -[[ "$RC" == 2 && "$ERR" == *runner_trust_unverifiable* ]] && pass "missing allows_public_repositories fails closed" || fail "missing flag: rc=$RC err=$ERR" -reset_api -run_check -[[ "$RC" == 2 ]] && pass "API failure fails closed" || fail "API failure: rc=$RC" - -# Repo scope. -reset_api -echo '{"private":false}' > "$T/api/repo.json" -GITHUB_RUNNER_SCOPE=repo GITHUB_RUNNER_REPO=app run_check -[[ "$RC" == 1 && "$ERR" == *runner_group_public* ]] && pass "public repo is refused with the same code" || fail "public repo: rc=$RC err=$ERR" -echo '{"private":true}' > "$T/api/repo.json" -GITHUB_RUNNER_SCOPE=repo GITHUB_RUNNER_REPO=app run_check -[[ "$RC" == 0 ]] && pass "private repo proceeds" || fail "private repo: rc=$RC err=$ERR" - -# deploy_host_warning -for e in prod production staging PROD; do - [[ -n "$(ENV=$e trust_deploy_host_warning)" ]] && pass "deploy_host_warning for ENV=$e" || fail "no warning for ENV=$e" -done -for e in "" dev development; do - [[ -z "$(ENV=$e trust_deploy_host_warning)" ]] && pass "no deploy_host_warning for ENV='$e'" || fail "unexpected warning for ENV='$e'" -done - -# install.sh refuses a public group: non-zero exit, same message, no PAT printed. -reset_api -printf '{"runner_groups":[%s]}' "$(group_json Default true true)" > "$T/api/groups-1.json" -rc=0; out=$(bash "$PLUGIN_DIR/install.sh" 2>&1) || rc=$? -[[ "$rc" == 1 && "$out" == *runner_group_public* ]] && pass "install.sh exits non-zero with runner_group_public" || fail "install.sh: rc=$rc" -[[ "$out" != *"$FAKE_PAT"* ]] && pass "install.sh output never prints the PAT" || fail "install.sh printed the PAT" -[[ ! -f "$T/home/.nself/runners/github-runner/config.sh" ]] && pass "install.sh stopped before downloading or configuring" || fail "install.sh went on after refusal" - -# A fine-grained token (no x-oauth-scopes header) must still reach the guard. -touch "$T/no-scopes" -rc=0; out=$(bash "$PLUGIN_DIR/install.sh" 2>&1) || rc=$? -[[ "$rc" == 1 && "$out" == *runner_group_public* ]] && pass "install.sh with a fine-grained token (no scopes header) reaches the guard" || fail "no scopes header: rc=$rc" -rm -f "$T/no-scopes" - -# An unknown scope never skips the repo check; names are validated. -reset_api -echo '{"private":false}' > "$T/api/repo.json" -GITHUB_RUNNER_SCOPE=repository GITHUB_RUNNER_REPO=app run_check -[[ "$RC" == 2 && "$ERR" == *runner_trust_unverifiable* ]] && pass "unknown scope fails closed" || fail "unknown scope: rc=$RC err=$ERR" -GITHUB_RUNNER_SCOPE=REPO GITHUB_RUNNER_REPO=app run_check -[[ "$RC" == 1 && "$ERR" == *runner_group_public* ]] && pass "scope is case-insensitive (REPO)" || fail "REPO: rc=$RC err=$ERR" -GITHUB_RUNNER_SCOPE=repo GITHUB_RUNNER_REPO='app/../x' run_check -[[ "$RC" == 2 ]] && pass "repo name with a slash fails closed" || fail "bad repo name: rc=$RC" -GITHUB_RUNNER_ORG='ac me' run_check -[[ "$RC" == 2 ]] && pass "org name with a space fails closed" || fail "bad org name: rc=$RC" -export GITHUB_RUNNER_ORG=acme -reset_api -printf '{"runner_groups":[%s]}' "$(group_json Default true true)" > "$T/api/groups-1.json" - -# start.sh refuses a public group and stops a running runner. -printf '#!/bin/sh\nexit 0\n' > "$T/home/.nself/runners/github-runner/run.sh"; chmod +x "$T/home/.nself/runners/github-runner/run.sh" -sleep 300 & sleeper=$! -echo "$sleeper" > "$T/home/.nself/logs/plugins/github-runner/runner.pid" -rc=0; out=$(bash "$PLUGIN_DIR/actions/start.sh" 2>&1) || rc=$? -sleep 0.2 -[[ "$rc" == 1 && "$out" == *runner_group_public* ]] && pass "start.sh refuses a public group" || fail "start.sh: rc=$rc out=$out" -if kill -0 "$sleeper" 2>/dev/null; then fail "start.sh left the runner running"; kill "$sleeper" 2>/dev/null || true; else pass "start.sh stops a runner whose group turned public"; fi -wait "$sleeper" 2>/dev/null || true - -# trust_stop_runner kills the whole process tree, not just the wrapper. -bash -c 'sleep 300 & echo $! > "$1"; wait' _ "$T/child.pid" & wrapper=$! -for _ in $(seq 1 100); do [[ -s "$T/child.pid" ]] && break; sleep 0.1; done -sleep 0.2 -echo "$wrapper" > "$T/tree.pid" -trust_stop_runner "$T/tree.pid" "no-such-unit" > /dev/null -child=$(cat "$T/child.pid") -for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do - state=$(ps -o stat= -p "$child" 2>/dev/null | tr -d ' ' || true) - [[ -z "$state" || "$state" == Z* ]] && break - sleep 0.2 -done -if [[ -n "$state" && "$state" != Z* ]]; then fail "trust_stop_runner left a child process running"; kill "$child" 2>/dev/null || true; else pass "trust_stop_runner stops the child processes too"; fi -wait "$wrapper" 2>/dev/null || true - -# A child that ignores TERM and outlives its wrapper (reparented) must still be -# killed: the KILL pass uses the tree listed before the first signal. -spawn_orphan_prone_tree() { # PIDFILE_BASENAME: wrapper dies on TERM, child ignores it - bash -c 'sh -c "trap \"\" TERM; exec sleep 300" & echo $! > "$1"; wait' _ "$T/$1.child" & wrapper=$! - for _ in $(seq 1 100); do [[ -s "$T/$1.child" ]] && break; sleep 0.1; done - sleep 0.3 - echo "$wrapper" > "$T/$1.pid" - child=$(cat "$T/$1.child") -} -child_gone() { # PID - local st i - for i in $(seq 1 25); do - st=$(ps -o stat= -p "$1" 2>/dev/null | tr -d ' ' || true) - [[ -z "$st" || "$st" == Z* ]] && return 0 - sleep 0.2 - done - return 1 -} -spawn_orphan_prone_tree orphan1 -rc=0; TRUST_STOP_GRACE=1 trust_stop_runner "$T/orphan1.pid" "no-such-unit" > /dev/null 2>&1 || rc=$? -if child_gone "$child" && [[ "$rc" == 0 ]]; then pass "trust_stop_runner KILLs a TERM-ignoring child that outlives its wrapper"; else fail "trust_stop_runner left a reparented child (rc=$rc)"; kill -KILL "$child" 2>/dev/null || true; fi -wait "$wrapper" 2>/dev/null || true - -spawn_orphan_prone_tree orphan2 -mkdir -p "$T/home/.nself/logs/plugins/github-runner" -cp "$T/orphan2.pid" "$T/home/.nself/logs/plugins/github-runner/runner.pid" -rc=0; TRUST_STOP_GRACE=1 bash "$PLUGIN_DIR/actions/stop.sh" > /dev/null 2>&1 || rc=$? -if child_gone "$child" && [[ "$rc" == 0 ]]; then pass "stop.sh KILLs a TERM-ignoring child that outlives its wrapper"; else fail "stop.sh left a reparented child (rc=$rc)"; kill -KILL "$child" 2>/dev/null || true; fi -wait "$wrapper" 2>/dev/null || true -rm -f "$T/home/.nself/logs/plugins/github-runner/runner.pid" - -# systemctl stop/disable failures must not be reported as success. -mkdir -p "$T/failbin" "$T/okbin" -printf '#!/bin/sh\nexec "$@"\n' > "$T/failbin/sudo" -printf '#!/bin/sh\ncase "$1" in is-active|is-enabled) exit 0 ;; *) exit 1 ;; esac\n' > "$T/failbin/systemctl" -cp "$T/failbin/sudo" "$T/okbin/sudo" -printf '#!/bin/sh\nexit 0\n' > "$T/okbin/systemctl" -chmod +x "$T/failbin/sudo" "$T/failbin/systemctl" "$T/okbin/sudo" "$T/okbin/systemctl" -rc=0; out=$(PATH="$T/failbin:$PATH" trust_stop_runner "$T/none.pid" "x.service" 2>&1) || rc=$? -[[ "$rc" != 0 ]] && pass "trust_stop_runner fails when systemctl stop and disable fail" || fail "trust_stop_runner returned 0 after systemctl failures" -[[ "$out" == *"could not stop systemd service"* && "$out" == *"could not disable systemd service"* ]] && pass "trust_stop_runner says which systemctl step failed" || fail "failure message: $out" -[[ "$out" != *"stopped systemd service"* && "$out" != *"disabled systemd service"* ]] && pass "trust_stop_runner does not claim a failed stop/disable" || fail "claimed success: $out" -rc=0; out=$(PATH="$T/okbin:$PATH" trust_stop_runner "$T/none.pid" "x.service" 2>&1) || rc=$? -[[ "$rc" == 0 && "$out" == *"stopped systemd service"* && "$out" == *"disabled systemd service"* ]] && pass "trust_stop_runner succeeds when systemctl succeeds" || fail "ok path: rc=$rc out=$out" -rc=0; out=$(PATH="$T/failbin:$PATH" bash "$PLUGIN_DIR/actions/start.sh" 2>&1) || rc=$? -[[ "$rc" == 1 && "$out" == *runner_group_public* && "$out" == *"could NOT be fully stopped"* ]] && pass "start.sh reports a runner it could not stop" || fail "start.sh with failing systemctl: rc=$rc out=$out" - -# status.sh stops the runner too and reports deploy_host_warning only in prod-like ENV. -sleep 300 & sleeper=$! -echo "$sleeper" > "$T/home/.nself/logs/plugins/github-runner/runner.pid" -out=$(ENV=prod bash "$PLUGIN_DIR/actions/status.sh" 2>&1) || true -sleep 0.2 -[[ "$out" == *runner_group_public* && "$out" == *deploy_host_warning* ]] && pass "status.sh reports the refusal and deploy_host_warning" || fail "status.sh: $out" -if kill -0 "$sleeper" 2>/dev/null; then fail "status.sh left the runner running"; kill "$sleeper" 2>/dev/null || true; else pass "status.sh stops a runner whose group turned public"; fi -wait "$sleeper" 2>/dev/null || true -out=$(ENV=dev bash "$PLUGIN_DIR/actions/status.sh" 2>&1) || true -[[ "$out" != *deploy_host_warning* ]] && pass "status.sh has no deploy_host_warning outside prod-like ENV" || fail "status.sh warned in dev" - -# ============================================================================= -# Summary -# ============================================================================= -echo "" -TOTAL=$((PASS + FAIL)) -echo "Results: $PASS/$TOTAL passed" -[[ $FAIL -eq 0 ]] && echo "All tests passed." || { echo "$FAIL test(s) failed."; exit 1; } diff --git a/free/github-runner/uninstall.sh b/free/github-runner/uninstall.sh deleted file mode 100755 index c175ff075..000000000 --- a/free/github-runner/uninstall.sh +++ /dev/null @@ -1,144 +0,0 @@ -#!/bin/bash -# ============================================================================= -# GitHub Actions Runner Plugin Uninstaller -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$(dirname "$PLUGIN_DIR")")/shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -RUNNER_DIR="${HOME}/.nself/runners/github-runner" -LOG_DIR="${HOME}/.nself/logs/plugins/github-runner" -PID_FILE="${LOG_DIR}/runner.pid" - -# ============================================================================= -# Helpers -# ============================================================================= - -get_removal_token() { - local scope="${GITHUB_RUNNER_SCOPE:-org}" - local token - - if [[ "$scope" == "org" ]]; then - token=$(curl -sSf -X POST \ - -H "Authorization: token ${GITHUB_RUNNER_PAT}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/orgs/${GITHUB_RUNNER_ORG}/actions/runners/remove-token" \ - | grep '"token"' | head -1 | sed 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') - else - local repo="${GITHUB_RUNNER_REPO:-}" - token=$(curl -sSf -X POST \ - -H "Authorization: token ${GITHUB_RUNNER_PAT}" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${GITHUB_RUNNER_ORG}/${repo}/actions/runners/remove-token" \ - | grep '"token"' | head -1 | sed 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') - fi - - echo "$token" -} - -stop_runner_service() { - # Try systemd first - if command -v systemctl &>/dev/null; then - local svc_name="actions.runner.${GITHUB_RUNNER_ORG:-nself-org}.${GITHUB_RUNNER_NAME:-$(hostname)-nself}" - if systemctl is-active --quiet "$svc_name" 2>/dev/null; then - plugin_info "Stopping systemd service..." - if [[ $EUID -eq 0 ]]; then - systemctl stop "$svc_name" 2>/dev/null || true - else - sudo systemctl stop "$svc_name" 2>/dev/null || true - fi - fi - - if [[ -f "${RUNNER_DIR}/svc.sh" ]]; then - ( - cd "$RUNNER_DIR" - if [[ $EUID -eq 0 ]]; then - ./svc.sh stop 2>/dev/null || true - ./svc.sh uninstall 2>/dev/null || true - else - sudo ./svc.sh stop 2>/dev/null || true - sudo ./svc.sh uninstall 2>/dev/null || true - fi - ) - fi - fi - - # Kill nohup process if PID file exists - if [[ -f "$PID_FILE" ]]; then - local pid - pid=$(cat "$PID_FILE") - if kill -0 "$pid" 2>/dev/null; then - plugin_info "Stopping runner process (PID: $pid)..." - kill "$pid" 2>/dev/null || true - sleep 2 - kill -9 "$pid" 2>/dev/null || true - fi - rm -f "$PID_FILE" - fi -} - -# ============================================================================= -# Uninstallation -# ============================================================================= - -uninstall_github_runner() { - plugin_info "Uninstalling GitHub Actions Runner plugin..." - - printf "\n" - printf "This will:\n" - printf " - Stop the runner process\n" - printf " - Remove runner registration from GitHub\n" - printf " - Delete runner binary and configuration\n" - printf "\n" - printf "Continue? (yes/no): " - read -r confirm - printf "\n" - - if [[ "$confirm" != "yes" ]]; then - plugin_warn "Uninstallation cancelled." - return 0 - fi - - # ------------------------------------------------------------------ - # 1. Stop the runner - # ------------------------------------------------------------------ - stop_runner_service - - # ------------------------------------------------------------------ - # 2. Remove runner registration from GitHub - # ------------------------------------------------------------------ - if [[ -n "${GITHUB_RUNNER_PAT:-}" && -n "${GITHUB_RUNNER_ORG:-}" ]]; then - plugin_info "Removing runner registration from GitHub..." - local remove_token - remove_token=$(get_removal_token 2>/dev/null || echo "") - - if [[ -n "$remove_token" && "$remove_token" != "null" && -f "${RUNNER_DIR}/config.sh" ]]; then - ( - cd "$RUNNER_DIR" - ./config.sh remove --token "$remove_token" --unattended 2>/dev/null || true - ) - plugin_success "Runner deregistered from GitHub." - else - plugin_warn "Could not get removal token — runner may still appear in GitHub settings." - plugin_warn "Remove manually at: https://github.com/organizations/${GITHUB_RUNNER_ORG}/settings/actions/runners" - fi - else - plugin_warn "GITHUB_RUNNER_PAT or GITHUB_RUNNER_ORG not set — skipping deregistration." - fi - - # ------------------------------------------------------------------ - # 3. Remove runner files - # ------------------------------------------------------------------ - plugin_info "Removing runner files..." - rm -rf "$RUNNER_DIR" - rm -rf "$LOG_DIR" - - plugin_success "GitHub Actions Runner plugin uninstalled." - printf "\n" -} - -uninstall_github_runner diff --git a/free/github/actions/issues.sh b/free/github/actions/issues.sh deleted file mode 100644 index 2dce13798..000000000 --- a/free/github/actions/issues.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env bash -# GitHub Plugin - Issues Action - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -show_help() { - echo "nself plugin github issues - Issue management" - echo "" - echo "Usage: nself plugin github issues [subcommand] [options]" - echo "" - echo "Subcommands:" - echo " list List issues" - echo " show Show issue details" - echo " open List open issues" - echo " closed List closed issues" - echo " stats Issue statistics" - echo "" - echo "Options:" - echo " --repo Filter by repository" - echo " --author Filter by author" - echo " --label Filter by label" - echo " --limit Limit results (default: 50)" - echo "" -} - -list_issues() { - local state="all" - local repo="" - local author="" - local label="" - local limit=50 - - while [[ $# -gt 0 ]]; do - case "$1" in - --state) state="$2"; shift 2 ;; - --repo) repo="$2"; shift 2 ;; - --author) author="$2"; shift 2 ;; - --label) label="$2"; shift 2 ;; - --limit) limit="$2"; shift 2 ;; - *) shift ;; - esac - done - - local where_clause="WHERE 1=1" - [[ "$state" != "all" ]] && where_clause+=" AND i.state = '$state'" - [[ -n "$repo" ]] && where_clause+=" AND r.full_name = '$repo'" - [[ -n "$author" ]] && where_clause+=" AND i.user_login = '$author'" - [[ -n "$label" ]] && where_clause+=" AND i.labels::text LIKE '%$label%'" - - printf "%-8s %-40s %-10s %-20s %-12s\n" "#" "Title" "State" "Repo" "Author" - printf "%-8s %-40s %-10s %-20s %-12s\n" "--------" "----------------------------------------" "----------" "--------------------" "------------" - - plugin_db_query "SELECT i.number, SUBSTRING(i.title, 1, 40), i.state, r.full_name, i.user_login - FROM github_issues i - JOIN github_repositories r ON i.repo_id = r.id - $where_clause - ORDER BY i.updated_at DESC - LIMIT $limit" | while IFS='|' read -r num title state repo_name author; do - printf "%-8s %-40s %-10s %-20s %-12s\n" "#$num" "$title" "$state" "$repo_name" "$author" - done -} - -show_issue() { - local issue_id="$1" - - if [[ -z "$issue_id" ]]; then - plugin_log "error" "Issue number or ID required" - return 1 - fi - - plugin_db_query "SELECT - 'Issue #' || i.number || ': ' || i.title, - 'Repository: ' || r.full_name, - 'State: ' || i.state, - 'Author: ' || i.user_login, - 'Labels: ' || COALESCE(i.labels::text, '[]'), - 'Comments: ' || i.comments, - 'Created: ' || i.created_at, - 'Updated: ' || i.updated_at, - 'URL: ' || i.html_url - FROM github_issues i - JOIN github_repositories r ON i.repo_id = r.id - WHERE i.id = $issue_id OR i.number = $issue_id - LIMIT 1" -} - -show_stats() { - echo "Issue Statistics" - echo "================" - echo "" - - echo "By State:" - plugin_db_query "SELECT state, COUNT(*) FROM github_issues GROUP BY state" - - echo "" - echo "Open Issues by Repo (Top 10):" - plugin_db_query "SELECT r.full_name, COUNT(*) - FROM github_issues i - JOIN github_repositories r ON i.repo_id = r.id - WHERE i.state = 'open' - GROUP BY r.full_name - ORDER BY COUNT(*) DESC - LIMIT 10" | while IFS='|' read -r repo count; do - printf " %-35s %s\n" "$repo" "$count" - done - - echo "" - echo "Top Issue Authors:" - plugin_db_query "SELECT user_login, COUNT(*) - FROM github_issues - GROUP BY user_login - ORDER BY COUNT(*) DESC - LIMIT 10" | while IFS='|' read -r author count; do - printf " %-20s %s\n" "$author" "$count" - done -} - -main() { - local subcommand="${1:-list}" - shift 2>/dev/null || true - - case "$subcommand" in - list) list_issues "$@" ;; - show) show_issue "$@" ;; - open) list_issues --state open "$@" ;; - closed) list_issues --state closed "$@" ;; - stats) show_stats ;; - -h|--help) show_help ;; - *) show_help; return 1 ;; - esac -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/free/github/actions/prs.sh b/free/github/actions/prs.sh deleted file mode 100644 index 9c631f22b..000000000 --- a/free/github/actions/prs.sh +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env bash -# GitHub Plugin - Pull Requests Action - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -show_help() { - echo "nself plugin github prs - Pull request management" - echo "" - echo "Usage: nself plugin github prs [subcommand] [options]" - echo "" - echo "Subcommands:" - echo " list List pull requests" - echo " show Show PR details" - echo " open List open PRs" - echo " merged List merged PRs" - echo " stats PR statistics" - echo "" - echo "Options:" - echo " --repo Filter by repository" - echo " --author Filter by author" - echo " --draft Include drafts only" - echo " --limit Limit results (default: 50)" - echo "" -} - -list_prs() { - local state="" - local repo="" - local author="" - local draft_only=false - local limit=50 - - while [[ $# -gt 0 ]]; do - case "$1" in - --state) state="$2"; shift 2 ;; - --repo) repo="$2"; shift 2 ;; - --author) author="$2"; shift 2 ;; - --draft) draft_only=true; shift ;; - --limit) limit="$2"; shift 2 ;; - *) shift ;; - esac - done - - local where_clause="WHERE 1=1" - [[ -n "$state" ]] && where_clause+=" AND p.state = '$state'" - [[ -n "$repo" ]] && where_clause+=" AND r.full_name = '$repo'" - [[ -n "$author" ]] && where_clause+=" AND p.user_login = '$author'" - [[ "$draft_only" == "true" ]] && where_clause+=" AND p.draft = true" - - printf "%-8s %-40s %-10s %-6s %-20s\n" "#" "Title" "State" "Merged" "Repo" - printf "%-8s %-40s %-10s %-6s %-20s\n" "--------" "----------------------------------------" "----------" "------" "--------------------" - - plugin_db_query "SELECT p.number, SUBSTRING(p.title, 1, 40), p.state, p.merged, r.full_name - FROM github_pull_requests p - JOIN github_repositories r ON p.repo_id = r.id - $where_clause - ORDER BY p.updated_at DESC - LIMIT $limit" | while IFS='|' read -r num title state merged repo_name; do - local merged_str="No" - [[ "$merged" == "t" ]] && merged_str="Yes" - printf "%-8s %-40s %-10s %-6s %-20s\n" "#$num" "$title" "$state" "$merged_str" "$repo_name" - done -} - -show_pr() { - local pr_id="$1" - - if [[ -z "$pr_id" ]]; then - plugin_log "error" "PR number or ID required" - return 1 - fi - - plugin_db_query "SELECT - 'PR #' || p.number || ': ' || p.title, - 'Repository: ' || r.full_name, - 'State: ' || p.state || CASE WHEN p.draft THEN ' (Draft)' ELSE '' END, - 'Author: ' || p.user_login, - 'Branch: ' || p.head_ref || ' → ' || p.base_ref, - 'Merged: ' || CASE WHEN p.merged THEN 'Yes' ELSE 'No' END, - 'Changes: +' || p.additions || ' -' || p.deletions || ' (' || p.changed_files || ' files)', - 'Comments: ' || p.comments || ' | Reviews: ' || p.review_comments, - 'Created: ' || p.created_at, - 'URL: ' || p.html_url - FROM github_pull_requests p - JOIN github_repositories r ON p.repo_id = r.id - WHERE p.id = $pr_id OR p.number = $pr_id - LIMIT 1" -} - -show_stats() { - echo "Pull Request Statistics" - echo "=======================" - echo "" - - echo "Summary:" - plugin_db_query "SELECT - 'Total PRs: ' || COUNT(*), - 'Open: ' || COUNT(*) FILTER (WHERE state = 'open'), - 'Merged: ' || COUNT(*) FILTER (WHERE merged = true), - 'Closed (not merged): ' || COUNT(*) FILTER (WHERE state = 'closed' AND merged = false) - FROM github_pull_requests" - - echo "" - echo "Top Contributors (by PRs merged):" - plugin_db_query "SELECT user_login, COUNT(*) - FROM github_pull_requests - WHERE merged = true - GROUP BY user_login - ORDER BY COUNT(*) DESC - LIMIT 10" | while IFS='|' read -r author count; do - printf " %-20s %s\n" "$author" "$count" - done - - echo "" - echo "Avg Changes per PR:" - plugin_db_query "SELECT - 'Additions: ' || ROUND(AVG(additions)), - 'Deletions: ' || ROUND(AVG(deletions)), - 'Files Changed: ' || ROUND(AVG(changed_files)) - FROM github_pull_requests WHERE merged = true" -} - -main() { - local subcommand="${1:-list}" - shift 2>/dev/null || true - - case "$subcommand" in - list) list_prs "$@" ;; - show) show_pr "$@" ;; - open) list_prs --state open "$@" ;; - merged) list_prs --state closed --merged true "$@" ;; - stats) show_stats ;; - -h|--help) show_help ;; - *) show_help; return 1 ;; - esac -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/free/github/actions/repos.sh b/free/github/actions/repos.sh deleted file mode 100644 index 4706bf7e1..000000000 --- a/free/github/actions/repos.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -# GitHub Plugin - Repository Management Action - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -show_help() { - echo "nself plugin github repos - Repository management" - echo "" - echo "Usage: nself plugin github repos [subcommand] [options]" - echo "" - echo "Subcommands:" - echo " list List synced repositories" - echo " show Show repository details" - echo " stats Show repository statistics" - echo " sync Sync repository list" - echo "" - echo "Options:" - echo " --org Filter by organization" - echo " --language Filter by language" - echo " --archived Include archived repos" - echo " --format Output format (table, json, csv)" - echo "" - echo "Examples:" - echo " nself plugin github repos list" - echo " nself plugin github repos show owner/repo" - echo " nself plugin github repos stats --org myorg" -} - -list_repos() { - local org="" - local language="" - local include_archived=false - local format="table" - - while [[ $# -gt 0 ]]; do - case "$1" in - --org) org="$2"; shift 2 ;; - --language) language="$2"; shift 2 ;; - --archived) include_archived=true; shift ;; - --format) format="$2"; shift 2 ;; - *) shift ;; - esac - done - - local where_clause="WHERE 1=1" - [[ -n "$org" ]] && where_clause+=" AND owner_login = '$org'" - [[ -n "$language" ]] && where_clause+=" AND language = '$language'" - [[ "$include_archived" != "true" ]] && where_clause+=" AND archived = false" - - local query="SELECT full_name, language, stargazers_count, forks_count, open_issues_count, updated_at - FROM github_repositories - $where_clause - ORDER BY stargazers_count DESC" - - if [[ "$format" == "json" ]]; then - plugin_db_query_json "$query" - else - printf "%-40s %-12s %6s %6s %6s %-20s\n" "Repository" "Language" "Stars" "Forks" "Issues" "Updated" - printf "%-40s %-12s %6s %6s %6s %-20s\n" "----------------------------------------" "------------" "------" "------" "------" "--------------------" - plugin_db_query "$query" | while IFS='|' read -r name lang stars forks issues updated; do - printf "%-40s %-12s %6s %6s %6s %-20s\n" "$name" "${lang:-n/a}" "$stars" "$forks" "$issues" "${updated:0:10}" - done - fi -} - -show_repo() { - local repo_name="$1" - - if [[ -z "$repo_name" ]]; then - plugin_log "error" "Repository name required" - return 1 - fi - - local data - data=$(plugin_db_query "SELECT * FROM github_repositories WHERE full_name = '$repo_name'" 2>/dev/null) - - if [[ -z "$data" ]]; then - plugin_log "error" "Repository not found: $repo_name" - return 1 - fi - - echo "Repository: $repo_name" - echo "========================" - plugin_db_query "SELECT - 'Owner: ' || owner_login, - 'Language: ' || COALESCE(language, 'n/a'), - 'Stars: ' || stargazers_count, - 'Forks: ' || forks_count, - 'Open Issues: ' || open_issues_count, - 'Default Branch: ' || default_branch, - 'Visibility: ' || visibility, - 'Archived: ' || archived, - 'Created: ' || created_at, - 'Updated: ' || updated_at - FROM github_repositories WHERE full_name = '$repo_name'" -} - -show_stats() { - echo "Repository Statistics" - echo "=====================" - echo "" - - echo "By Language:" - plugin_db_query "SELECT COALESCE(language, 'Unknown') as lang, COUNT(*) as count - FROM github_repositories - WHERE archived = false - GROUP BY language - ORDER BY count DESC - LIMIT 10" | while IFS='|' read -r lang count; do - printf " %-20s %s\n" "$lang" "$count" - done - - echo "" - echo "By Owner:" - plugin_db_query "SELECT owner_login, COUNT(*) as count - FROM github_repositories - GROUP BY owner_login - ORDER BY count DESC - LIMIT 10" | while IFS='|' read -r owner count; do - printf " %-20s %s\n" "$owner" "$count" - done - - echo "" - echo "Totals:" - plugin_db_query "SELECT - 'Total Repos: ' || COUNT(*), - 'Total Stars: ' || SUM(stargazers_count), - 'Total Forks: ' || SUM(forks_count), - 'Total Issues: ' || SUM(open_issues_count) - FROM github_repositories WHERE archived = false" -} - -main() { - local subcommand="${1:-list}" - shift 2>/dev/null || true - - case "$subcommand" in - list) list_repos "$@" ;; - show) show_repo "$@" ;; - stats) show_stats "$@" ;; - sync) bash "${PLUGIN_DIR}/actions/sync.sh" --repos-only ;; - -h|--help) show_help ;; - *) show_help; return 1 ;; - esac -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/free/github/actions/sync.sh b/free/github/actions/sync.sh deleted file mode 100644 index 01baffc47..000000000 --- a/free/github/actions/sync.sh +++ /dev/null @@ -1,422 +0,0 @@ -#!/usr/bin/env bash -# GitHub Plugin - Sync Action -# Syncs repository data from GitHub API - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PLUGIN_NAME="github" - -# Source shared utilities -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -# ============================================================================ -# Configuration -# ============================================================================ - -GITHUB_API="https://api.github.com" -API_VERSION="2022-11-28" -PER_PAGE=100 - -# ============================================================================ -# API Helpers -# ============================================================================ - -github_api() { - local endpoint="$1" - local method="${2:-GET}" - - curl -s \ - -X "$method" \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: $API_VERSION" \ - "${GITHUB_API}${endpoint}" -} - -github_api_paginated() { - local endpoint="$1" - local page=1 - local results="[]" - - while true; do - local response - response=$(github_api "${endpoint}?per_page=${PER_PAGE}&page=${page}") - - # Check if empty or error - if [[ -z "$response" ]] || [[ "$response" == "[]" ]]; then - break - fi - - # Check for error response - if echo "$response" | grep -q '"message"'; then - plugin_log "error" "API error: $(echo "$response" | grep -o '"message":"[^"]*"')" - break - fi - - # Merge results - results=$(echo "$results $response" | jq -s 'add') - - # Check if we got less than a full page - local count - count=$(echo "$response" | jq 'length') - if [[ "$count" -lt "$PER_PAGE" ]]; then - break - fi - - ((page++)) - - # Rate limit protection - sleep 0.1 - done - - echo "$results" -} - -# ============================================================================ -# Sync Functions -# ============================================================================ - -sync_repositories() { - plugin_log "info" "Syncing repositories..." - - local repos - - if [[ -n "${GITHUB_ORG:-}" ]]; then - repos=$(github_api_paginated "/orgs/${GITHUB_ORG}/repos") - elif [[ -n "${GITHUB_REPOS:-}" ]]; then - # Sync specific repos - repos="[]" - IFS=',' read -ra REPO_LIST <<< "$GITHUB_REPOS" - for repo in "${REPO_LIST[@]}"; do - repo=$(echo "$repo" | xargs) # Trim whitespace - local repo_data - repo_data=$(github_api "/repos/${repo}") - repos=$(echo "$repos [$repo_data]" | jq -s 'add') - done - else - repos=$(github_api_paginated "/user/repos") - fi - - local count - count=$(echo "$repos" | jq 'length') - plugin_log "info" "Found $count repositories" - - # Insert/update repos - echo "$repos" | jq -c '.[]' | while read -r repo; do - local id name full_name owner_login - id=$(echo "$repo" | jq -r '.id') - name=$(echo "$repo" | jq -r '.name') - full_name=$(echo "$repo" | jq -r '.full_name') - owner_login=$(echo "$repo" | jq -r '.owner.login') - - plugin_log "debug" "Syncing repo: $full_name" - - # Upsert repository - plugin_db_query "INSERT INTO github_repositories ( - id, node_id, name, full_name, owner_login, owner_type, - private, description, fork, url, html_url, clone_url, ssh_url, - homepage, language, default_branch, size, stargazers_count, - watchers_count, forks_count, open_issues_count, topics, - visibility, archived, disabled, pushed_at, created_at, updated_at - ) VALUES ( - $(echo "$repo" | jq '.id'), - $(echo "$repo" | jq '.node_id'), - $(echo "$repo" | jq '.name'), - $(echo "$repo" | jq '.full_name'), - $(echo "$repo" | jq '.owner.login'), - $(echo "$repo" | jq '.owner.type'), - $(echo "$repo" | jq '.private'), - $(echo "$repo" | jq '.description'), - $(echo "$repo" | jq '.fork'), - $(echo "$repo" | jq '.url'), - $(echo "$repo" | jq '.html_url'), - $(echo "$repo" | jq '.clone_url'), - $(echo "$repo" | jq '.ssh_url'), - $(echo "$repo" | jq '.homepage'), - $(echo "$repo" | jq '.language'), - $(echo "$repo" | jq '.default_branch'), - $(echo "$repo" | jq '.size'), - $(echo "$repo" | jq '.stargazers_count'), - $(echo "$repo" | jq '.watchers_count'), - $(echo "$repo" | jq '.forks_count'), - $(echo "$repo" | jq '.open_issues_count'), - $(echo "$repo" | jq '.topics'), - $(echo "$repo" | jq '.visibility'), - $(echo "$repo" | jq '.archived'), - $(echo "$repo" | jq '.disabled'), - $(echo "$repo" | jq '.pushed_at'), - $(echo "$repo" | jq '.created_at'), - $(echo "$repo" | jq '.updated_at') - ) ON CONFLICT (id) DO UPDATE SET - name = EXCLUDED.name, - full_name = EXCLUDED.full_name, - description = EXCLUDED.description, - language = EXCLUDED.language, - stargazers_count = EXCLUDED.stargazers_count, - watchers_count = EXCLUDED.watchers_count, - forks_count = EXCLUDED.forks_count, - open_issues_count = EXCLUDED.open_issues_count, - topics = EXCLUDED.topics, - visibility = EXCLUDED.visibility, - archived = EXCLUDED.archived, - pushed_at = EXCLUDED.pushed_at, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - done - - plugin_log "success" "Repositories synced" -} - -sync_issues() { - local repo_id="$1" - local full_name="$2" - - plugin_log "info" "Syncing issues for $full_name..." - - local issues - issues=$(github_api_paginated "/repos/${full_name}/issues?state=all") - - local count - count=$(echo "$issues" | jq '[.[] | select(.pull_request == null)] | length') - plugin_log "debug" "Found $count issues" - - # Filter out PRs (they come in issues endpoint) - echo "$issues" | jq -c '.[] | select(.pull_request == null)' | while read -r issue; do - local id number title state - id=$(echo "$issue" | jq -r '.id') - number=$(echo "$issue" | jq -r '.number') - - plugin_db_query "INSERT INTO github_issues ( - id, node_id, repo_id, number, title, body, state, state_reason, - locked, user_login, user_id, labels, assignees, milestone, - comments, reactions, html_url, closed_at, closed_by_login, - created_at, updated_at - ) VALUES ( - $(echo "$issue" | jq '.id'), - $(echo "$issue" | jq '.node_id'), - $repo_id, - $(echo "$issue" | jq '.number'), - $(echo "$issue" | jq '.title'), - $(echo "$issue" | jq '.body'), - $(echo "$issue" | jq '.state'), - $(echo "$issue" | jq '.state_reason'), - $(echo "$issue" | jq '.locked'), - $(echo "$issue" | jq '.user.login'), - $(echo "$issue" | jq '.user.id'), - $(echo "$issue" | jq '.labels'), - $(echo "$issue" | jq '.assignees'), - $(echo "$issue" | jq '.milestone'), - $(echo "$issue" | jq '.comments'), - $(echo "$issue" | jq '.reactions'), - $(echo "$issue" | jq '.html_url'), - $(echo "$issue" | jq '.closed_at'), - $(echo "$issue" | jq '.closed_by.login // null'), - $(echo "$issue" | jq '.created_at'), - $(echo "$issue" | jq '.updated_at') - ) ON CONFLICT (id) DO UPDATE SET - title = EXCLUDED.title, - body = EXCLUDED.body, - state = EXCLUDED.state, - state_reason = EXCLUDED.state_reason, - labels = EXCLUDED.labels, - assignees = EXCLUDED.assignees, - comments = EXCLUDED.comments, - closed_at = EXCLUDED.closed_at, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - done -} - -sync_pull_requests() { - local repo_id="$1" - local full_name="$2" - - plugin_log "info" "Syncing pull requests for $full_name..." - - local prs - prs=$(github_api_paginated "/repos/${full_name}/pulls?state=all") - - local count - count=$(echo "$prs" | jq 'length') - plugin_log "debug" "Found $count pull requests" - - echo "$prs" | jq -c '.[]' | while read -r pr; do - plugin_db_query "INSERT INTO github_pull_requests ( - id, node_id, repo_id, number, title, body, state, draft, locked, - user_login, user_id, head_ref, head_sha, base_ref, base_sha, - merged, mergeable, mergeable_state, merged_by_login, merged_at, - merge_commit_sha, labels, assignees, reviewers, milestone, - comments, review_comments, commits, additions, deletions, - changed_files, html_url, diff_url, closed_at, created_at, updated_at - ) VALUES ( - $(echo "$pr" | jq '.id'), - $(echo "$pr" | jq '.node_id'), - $repo_id, - $(echo "$pr" | jq '.number'), - $(echo "$pr" | jq '.title'), - $(echo "$pr" | jq '.body'), - $(echo "$pr" | jq '.state'), - $(echo "$pr" | jq '.draft'), - $(echo "$pr" | jq '.locked'), - $(echo "$pr" | jq '.user.login'), - $(echo "$pr" | jq '.user.id'), - $(echo "$pr" | jq '.head.ref'), - $(echo "$pr" | jq '.head.sha'), - $(echo "$pr" | jq '.base.ref'), - $(echo "$pr" | jq '.base.sha'), - $(echo "$pr" | jq '.merged'), - $(echo "$pr" | jq '.mergeable'), - $(echo "$pr" | jq '.mergeable_state'), - $(echo "$pr" | jq '.merged_by.login // null'), - $(echo "$pr" | jq '.merged_at'), - $(echo "$pr" | jq '.merge_commit_sha'), - $(echo "$pr" | jq '.labels'), - $(echo "$pr" | jq '.assignees'), - $(echo "$pr" | jq '[.requested_reviewers[].login]'), - $(echo "$pr" | jq '.milestone'), - $(echo "$pr" | jq '.comments // 0'), - $(echo "$pr" | jq '.review_comments // 0'), - $(echo "$pr" | jq '.commits // 0'), - $(echo "$pr" | jq '.additions // 0'), - $(echo "$pr" | jq '.deletions // 0'), - $(echo "$pr" | jq '.changed_files // 0'), - $(echo "$pr" | jq '.html_url'), - $(echo "$pr" | jq '.diff_url'), - $(echo "$pr" | jq '.closed_at'), - $(echo "$pr" | jq '.created_at'), - $(echo "$pr" | jq '.updated_at') - ) ON CONFLICT (id) DO UPDATE SET - title = EXCLUDED.title, - body = EXCLUDED.body, - state = EXCLUDED.state, - draft = EXCLUDED.draft, - merged = EXCLUDED.merged, - merged_at = EXCLUDED.merged_at, - labels = EXCLUDED.labels, - assignees = EXCLUDED.assignees, - closed_at = EXCLUDED.closed_at, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - done -} - -sync_workflow_runs() { - local repo_id="$1" - local full_name="$2" - - plugin_log "info" "Syncing workflow runs for $full_name..." - - local runs - runs=$(github_api "/repos/${full_name}/actions/runs?per_page=50") - - if echo "$runs" | jq -e '.workflow_runs' > /dev/null 2>&1; then - echo "$runs" | jq -c '.workflow_runs[]' | while read -r run; do - plugin_db_query "INSERT INTO github_workflow_runs ( - id, node_id, repo_id, workflow_id, workflow_name, name, - head_branch, head_sha, run_number, run_attempt, event, - status, conclusion, actor_login, triggering_actor_login, - html_url, jobs_url, logs_url, run_started_at, created_at, updated_at - ) VALUES ( - $(echo "$run" | jq '.id'), - $(echo "$run" | jq '.node_id'), - $repo_id, - $(echo "$run" | jq '.workflow_id'), - $(echo "$run" | jq '.name'), - $(echo "$run" | jq '.display_title'), - $(echo "$run" | jq '.head_branch'), - $(echo "$run" | jq '.head_sha'), - $(echo "$run" | jq '.run_number'), - $(echo "$run" | jq '.run_attempt'), - $(echo "$run" | jq '.event'), - $(echo "$run" | jq '.status'), - $(echo "$run" | jq '.conclusion'), - $(echo "$run" | jq '.actor.login'), - $(echo "$run" | jq '.triggering_actor.login'), - $(echo "$run" | jq '.html_url'), - $(echo "$run" | jq '.jobs_url'), - $(echo "$run" | jq '.logs_url'), - $(echo "$run" | jq '.run_started_at'), - $(echo "$run" | jq '.created_at'), - $(echo "$run" | jq '.updated_at') - ) ON CONFLICT (id) DO UPDATE SET - status = EXCLUDED.status, - conclusion = EXCLUDED.conclusion, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - done - fi -} - -# ============================================================================ -# Main Sync -# ============================================================================ - -main() { - local initial=false - local full=false - local repos_only=false - - while [[ $# -gt 0 ]]; do - case "$1" in - --initial) - initial=true - shift - ;; - --full) - full=true - shift - ;; - --repos-only) - repos_only=true - shift - ;; - *) - shift - ;; - esac - done - - plugin_log "info" "Starting GitHub sync..." - - # Check token - if [[ -z "${GITHUB_TOKEN:-}" ]]; then - plugin_log "error" "GITHUB_TOKEN not set" - return 1 - fi - - # Sync repositories first - sync_repositories - - if [[ "$repos_only" == "true" ]]; then - plugin_log "success" "Sync complete (repos only)" - return 0 - fi - - # Sync details for each repository - plugin_db_query "SELECT id, full_name FROM github_repositories" | while read -r line; do - local repo_id full_name - repo_id=$(echo "$line" | cut -d'|' -f1 | xargs) - full_name=$(echo "$line" | cut -d'|' -f2 | xargs) - - if [[ -n "$repo_id" ]] && [[ -n "$full_name" ]]; then - sync_issues "$repo_id" "$full_name" - sync_pull_requests "$repo_id" "$full_name" - sync_workflow_runs "$repo_id" "$full_name" - - # Rate limit protection - sleep 0.5 - fi - done - - # Update sync timestamp - plugin_set_meta "$PLUGIN_NAME" "last_sync" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" - - plugin_log "success" "GitHub sync complete" - return 0 -} - -# Run if executed directly -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi diff --git a/free/github/actions/webhook.sh b/free/github/actions/webhook.sh deleted file mode 100644 index 957448c8c..000000000 --- a/free/github/actions/webhook.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env bash -# GitHub Plugin - Webhook Management Action - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -show_help() { - echo "nself plugin github webhook - Webhook event management" - echo "" - echo "Usage: nself plugin github webhook [subcommand] [options]" - echo "" - echo "Subcommands:" - echo " list List received webhook events" - echo " show Show event details" - echo " pending List unprocessed events" - echo " retry Retry processing an event" - echo " stats Webhook statistics" - echo "" - echo "Options:" - echo " --event Filter by event type" - echo " --repo Filter by repository" - echo " --limit Limit results (default: 50)" - echo "" -} - -list_events() { - local event_type="" - local repo="" - local limit=50 - - while [[ $# -gt 0 ]]; do - case "$1" in - --event) event_type="$2"; shift 2 ;; - --repo) repo="$2"; shift 2 ;; - --limit) limit="$2"; shift 2 ;; - *) shift ;; - esac - done - - local where_clause="WHERE 1=1" - [[ -n "$event_type" ]] && where_clause+=" AND event = '$event_type'" - [[ -n "$repo" ]] && where_clause+=" AND repo_full_name = '$repo'" - - printf "%-36s %-20s %-15s %-10s %-20s\n" "ID" "Event" "Action" "Processed" "Received" - printf "%-36s %-20s %-15s %-10s %-20s\n" "------------------------------------" "--------------------" "---------------" "----------" "--------------------" - - plugin_db_query "SELECT id, event, COALESCE(action, '-'), processed, received_at - FROM github_webhook_events - $where_clause - ORDER BY received_at DESC - LIMIT $limit" | while IFS='|' read -r id event action processed received; do - local proc_str="No" - [[ "$processed" == "t" ]] && proc_str="Yes" - printf "%-36s %-20s %-15s %-10s %-20s\n" "${id:0:36}" "$event" "$action" "$proc_str" "${received:0:19}" - done -} - -show_event() { - local event_id="$1" - - if [[ -z "$event_id" ]]; then - plugin_log "error" "Event ID required" - return 1 - fi - - echo "Webhook Event Details" - echo "=====================" - - plugin_db_query "SELECT - 'ID: ' || id, - 'Event: ' || event, - 'Action: ' || COALESCE(action, 'n/a'), - 'Repository: ' || COALESCE(repo_full_name, 'n/a'), - 'Sender: ' || COALESCE(sender_login, 'n/a'), - 'Processed: ' || processed, - 'Processed At: ' || COALESCE(processed_at::text, 'n/a'), - 'Error: ' || COALESCE(error, 'none'), - 'Received: ' || received_at - FROM github_webhook_events - WHERE id = '$event_id'" - - echo "" - echo "Payload (first 500 chars):" - plugin_db_query "SELECT SUBSTRING(data::text, 1, 500) FROM github_webhook_events WHERE id = '$event_id'" -} - -list_pending() { - list_events --processed false "$@" -} - -retry_event() { - local event_id="$1" - - if [[ -z "$event_id" ]]; then - plugin_log "error" "Event ID required" - return 1 - fi - - # Get event data - local event_data - event_data=$(plugin_db_query "SELECT event, action, data FROM github_webhook_events WHERE id = '$event_id'" 2>/dev/null) - - if [[ -z "$event_data" ]]; then - plugin_log "error" "Event not found: $event_id" - return 1 - fi - - plugin_log "info" "Retrying event: $event_id" - - # Reset processed status - plugin_db_query "UPDATE github_webhook_events SET processed = false, error = NULL WHERE id = '$event_id'" - - # Dispatch to handler - local event_type action - event_type=$(echo "$event_data" | cut -d'|' -f1) - action=$(echo "$event_data" | cut -d'|' -f2) - - if [[ -x "${PLUGIN_DIR}/webhooks/handler.sh" ]]; then - # Re-process the event - bash "${PLUGIN_DIR}/webhooks/handler.sh" "$event_id" - plugin_log "success" "Event reprocessed" - else - plugin_log "error" "Webhook handler not found" - return 1 - fi -} - -show_stats() { - echo "Webhook Statistics" - echo "==================" - echo "" - - echo "By Event Type:" - plugin_db_query "SELECT event, COUNT(*), COUNT(*) FILTER (WHERE processed = true) - FROM github_webhook_events - GROUP BY event - ORDER BY COUNT(*) DESC" | while IFS='|' read -r event total processed; do - printf " %-25s %5s total, %5s processed\n" "$event" "$total" "$processed" - done - - echo "" - echo "Recent Errors:" - plugin_db_query "SELECT event, error, received_at - FROM github_webhook_events - WHERE error IS NOT NULL - ORDER BY received_at DESC - LIMIT 5" | while IFS='|' read -r event error received; do - printf " %-20s %s\n" "$event" "${error:0:50}" - done -} - -main() { - local subcommand="${1:-list}" - shift 2>/dev/null || true - - case "$subcommand" in - list) list_events "$@" ;; - show) show_event "$@" ;; - pending) list_pending "$@" ;; - retry) retry_event "$@" ;; - stats) show_stats ;; - -h|--help) show_help ;; - *) show_help; return 1 ;; - esac -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/free/github/actions/workflows.sh b/free/github/actions/workflows.sh deleted file mode 100644 index 2b26e013a..000000000 --- a/free/github/actions/workflows.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env bash -# GitHub Plugin - Workflow Runs Action - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -show_help() { - echo "nself plugin github actions - GitHub Actions workflow management" - echo "" - echo "Usage: nself plugin github actions [subcommand] [options]" - echo "" - echo "Subcommands:" - echo " list List workflow runs" - echo " show Show run details" - echo " failed List failed runs" - echo " stats Workflow statistics" - echo "" - echo "Options:" - echo " --repo Filter by repository" - echo " --workflow Filter by workflow name" - echo " --status Filter by status" - echo " --limit Limit results (default: 50)" - echo "" -} - -list_runs() { - local repo="" - local workflow="" - local status="" - local limit=50 - - while [[ $# -gt 0 ]]; do - case "$1" in - --repo) repo="$2"; shift 2 ;; - --workflow) workflow="$2"; shift 2 ;; - --status) status="$2"; shift 2 ;; - --limit) limit="$2"; shift 2 ;; - *) shift ;; - esac - done - - local where_clause="WHERE 1=1" - [[ -n "$repo" ]] && where_clause+=" AND r.full_name = '$repo'" - [[ -n "$workflow" ]] && where_clause+=" AND w.workflow_name LIKE '%$workflow%'" - [[ -n "$status" ]] && where_clause+=" AND w.conclusion = '$status'" - - printf "%-10s %-25s %-10s %-12s %-20s\n" "Run#" "Workflow" "Status" "Conclusion" "Repo" - printf "%-10s %-25s %-10s %-12s %-20s\n" "----------" "-------------------------" "----------" "------------" "--------------------" - - plugin_db_query "SELECT w.run_number, SUBSTRING(w.workflow_name, 1, 25), w.status, w.conclusion, r.full_name - FROM github_workflow_runs w - JOIN github_repositories r ON w.repo_id = r.id - $where_clause - ORDER BY w.created_at DESC - LIMIT $limit" | while IFS='|' read -r num name status conclusion repo_name; do - local status_icon="" - case "$conclusion" in - success) status_icon="✓" ;; - failure) status_icon="✗" ;; - cancelled) status_icon="○" ;; - *) status_icon="?" ;; - esac - printf "%-10s %-25s %-10s %-12s %-20s\n" "#$num" "$name" "$status" "$status_icon $conclusion" "$repo_name" - done -} - -show_run() { - local run_id="$1" - - if [[ -z "$run_id" ]]; then - plugin_log "error" "Run ID required" - return 1 - fi - - plugin_db_query "SELECT - 'Run #' || w.run_number || ' - ' || w.workflow_name, - 'Repository: ' || r.full_name, - 'Status: ' || w.status, - 'Conclusion: ' || COALESCE(w.conclusion, 'in_progress'), - 'Event: ' || w.event, - 'Branch: ' || w.head_branch, - 'Actor: ' || w.actor_login, - 'Started: ' || w.run_started_at, - 'URL: ' || w.html_url - FROM github_workflow_runs w - JOIN github_repositories r ON w.repo_id = r.id - WHERE w.id = $run_id OR w.run_number = $run_id - LIMIT 1" -} - -show_stats() { - echo "Workflow Statistics (Last 30 Days)" - echo "===================================" - echo "" - - # Use view if exists, otherwise direct query - plugin_db_query "SELECT repo, workflow_name, total_runs, success, failure, success_rate - FROM github_workflow_stats - ORDER BY total_runs DESC - LIMIT 15" 2>/dev/null || \ - plugin_db_query "SELECT r.full_name, w.workflow_name, COUNT(*), - COUNT(*) FILTER (WHERE w.conclusion = 'success'), - COUNT(*) FILTER (WHERE w.conclusion = 'failure'), - ROUND(COUNT(*) FILTER (WHERE w.conclusion = 'success')::numeric / NULLIF(COUNT(*), 0) * 100, 2) - FROM github_workflow_runs w - JOIN github_repositories r ON w.repo_id = r.id - WHERE w.created_at > NOW() - INTERVAL '30 days' - GROUP BY r.full_name, w.workflow_name - ORDER BY COUNT(*) DESC - LIMIT 15" | while IFS='|' read -r repo workflow total success failure rate; do - printf "%-25s %-20s %5s runs (%s%% success)\n" "$repo" "$workflow" "$total" "$rate" - done -} - -list_failed() { - list_runs --status failure "$@" -} - -main() { - local subcommand="${1:-list}" - shift 2>/dev/null || true - - case "$subcommand" in - list) list_runs "$@" ;; - show) show_run "$@" ;; - failed) list_failed "$@" ;; - stats) show_stats ;; - -h|--help) show_help ;; - *) show_help; return 1 ;; - esac -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/free/github/install.sh b/free/github/install.sh deleted file mode 100644 index c857cb7df..000000000 --- a/free/github/install.sh +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env bash -# GitHub Plugin - Installation Script - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PLUGIN_NAME="github" - -# Source shared utilities -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -# ============================================================================ -# Pre-flight Checks -# ============================================================================ - -preflight_checks() { - plugin_log "info" "Running pre-flight checks..." - - # Check required environment variables - if [[ -z "${GITHUB_TOKEN:-}" ]]; then - plugin_log "error" "GITHUB_TOKEN is required" - plugin_log "info" "Generate a token at: https://github.com/settings/tokens" - plugin_log "info" "Required scopes: repo, read:org (for org repos)" - return 1 - fi - - # Validate token format - if [[ ! "$GITHUB_TOKEN" =~ ^(ghp_|github_pat_|gho_|ghu_|ghs_|ghr_) ]]; then - plugin_log "warning" "Token format doesn't match known GitHub token patterns" - plugin_log "info" "Expected: ghp_*, github_pat_*, gho_*, ghu_*, ghs_*, or ghr_*" - fi - - # Test API access - plugin_log "info" "Testing GitHub API access..." - local response - response=$(curl -s -o /dev/null -w "%{http_code}" \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/user") - - if [[ "$response" != "200" ]]; then - plugin_log "error" "GitHub API returned status $response" - plugin_log "info" "Check your GITHUB_TOKEN is valid and not expired" - return 1 - fi - - plugin_log "success" "GitHub API access verified" - return 0 -} - -# ============================================================================ -# Database Setup -# ============================================================================ - -setup_database() { - plugin_log "info" "Setting up database schema..." - - # Check if schema exists - local tables_exist - tables_exist=$(plugin_db_query "SELECT COUNT(*) FROM information_schema.tables WHERE table_name LIKE 'github_%'" 2>/dev/null || echo "0") - - if [[ "$tables_exist" -gt 0 ]]; then - plugin_log "info" "GitHub tables already exist, checking for updates..." - - # Run migration checks here if needed - return 0 - fi - - # Apply schema - if [[ -f "${PLUGIN_DIR}/schema/tables.sql" ]]; then - plugin_log "info" "Applying database schema..." - plugin_db_exec_file "${PLUGIN_DIR}/schema/tables.sql" - plugin_log "success" "Database schema applied" - else - plugin_log "error" "Schema file not found: ${PLUGIN_DIR}/schema/tables.sql" - return 1 - fi - - return 0 -} - -# ============================================================================ -# Webhook Setup -# ============================================================================ - -setup_webhooks() { - plugin_log "info" "Configuring webhook handlers..." - - # Register webhook endpoint with nself - local webhook_path="${GITHUB_WEBHOOK_PATH:-/webhooks/github}" - - plugin_log "info" "Webhook endpoint: $webhook_path" - - if [[ -n "${GITHUB_WEBHOOK_SECRET:-}" ]]; then - plugin_log "success" "Webhook signature verification enabled" - else - plugin_log "warning" "GITHUB_WEBHOOK_SECRET not set - webhook signatures won't be verified" - plugin_log "info" "Set GITHUB_WEBHOOK_SECRET for production use" - fi - - # Copy webhook handlers to runtime location if needed - local webhook_runtime_dir="${HOME}/.nself/plugins/${PLUGIN_NAME}/webhooks" - mkdir -p "$webhook_runtime_dir" - - if [[ -d "${PLUGIN_DIR}/webhooks" ]]; then - cp -r "${PLUGIN_DIR}/webhooks/"* "$webhook_runtime_dir/" 2>/dev/null || true - chmod +x "$webhook_runtime_dir"/*.sh 2>/dev/null || true - chmod +x "$webhook_runtime_dir/events/"*.sh 2>/dev/null || true - fi - - return 0 -} - -# ============================================================================ -# Initial Sync -# ============================================================================ - -initial_sync() { - plugin_log "info" "Running initial data sync..." - - # Determine repositories to sync - local repos_to_sync="" - - if [[ -n "${GITHUB_REPOS:-}" ]]; then - # Specific repos configured - repos_to_sync="$GITHUB_REPOS" - plugin_log "info" "Syncing specified repos: $repos_to_sync" - elif [[ -n "${GITHUB_ORG:-}" ]]; then - # Sync org repos - plugin_log "info" "Syncing repos for org: $GITHUB_ORG" - else - # Sync user repos - plugin_log "info" "Syncing authenticated user's repos" - fi - - # Run sync action - if [[ -x "${PLUGIN_DIR}/actions/sync.sh" ]]; then - bash "${PLUGIN_DIR}/actions/sync.sh" --initial - else - plugin_log "warning" "Sync action not found, skipping initial sync" - fi - - return 0 -} - -# ============================================================================ -# Main Installation -# ============================================================================ - -main() { - plugin_log "info" "Installing GitHub plugin..." - - # Run pre-flight checks - if ! preflight_checks; then - plugin_log "error" "Pre-flight checks failed" - return 1 - fi - - # Setup database - if ! setup_database; then - plugin_log "error" "Database setup failed" - return 1 - fi - - # Setup webhooks - if ! setup_webhooks; then - plugin_log "error" "Webhook setup failed" - return 1 - fi - - # Initial sync (optional, can fail) - if [[ "${SKIP_INITIAL_SYNC:-}" != "true" ]]; then - initial_sync || plugin_log "warning" "Initial sync had issues, continuing..." - fi - - # Mark as installed - plugin_mark_installed "$PLUGIN_NAME" - - plugin_log "success" "GitHub plugin installed successfully" - - # Show usage - echo "" - echo "Usage:" - echo " nself plugin github sync - Sync repository data" - echo " nself plugin github repos - List repositories" - echo " nself plugin github issues - View issues" - echo " nself plugin github prs - View pull requests" - echo " nself plugin github actions - View workflow runs" - echo "" - - return 0 -} - -# Run if executed directly -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi diff --git a/free/github/uninstall.sh b/free/github/uninstall.sh deleted file mode 100644 index f10340264..000000000 --- a/free/github/uninstall.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# GitHub Plugin - Uninstall Script - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PLUGIN_NAME="github" - -# Source shared utilities -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -# ============================================================================ -# Uninstallation -# ============================================================================ - -remove_database() { - local keep_data="${1:-false}" - - if [[ "$keep_data" == "true" ]]; then - plugin_log "info" "Keeping database tables (--keep-data specified)" - return 0 - fi - - plugin_log "warning" "Removing GitHub database tables..." - - # Drop views first (they depend on tables) - plugin_db_query "DROP VIEW IF EXISTS github_workflow_stats CASCADE" 2>/dev/null || true - plugin_db_query "DROP VIEW IF EXISTS github_recent_activity CASCADE" 2>/dev/null || true - plugin_db_query "DROP VIEW IF EXISTS github_open_items CASCADE" 2>/dev/null || true - - # Drop tables in reverse dependency order - plugin_db_query "DROP TABLE IF EXISTS github_webhook_events CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS github_deployments CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS github_workflow_runs CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS github_releases CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS github_commits CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS github_pull_requests CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS github_issues CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS github_repositories CASCADE" 2>/dev/null || true - - plugin_log "success" "Database tables removed" - return 0 -} - -remove_webhooks() { - plugin_log "info" "Removing webhook handlers..." - - local webhook_runtime_dir="${HOME}/.nself/plugins/${PLUGIN_NAME}/webhooks" - - if [[ -d "$webhook_runtime_dir" ]]; then - rm -rf "$webhook_runtime_dir" - plugin_log "success" "Webhook handlers removed" - fi - - return 0 -} - -remove_cache() { - plugin_log "info" "Clearing plugin cache..." - - local cache_dir="${HOME}/.nself/cache/plugins/${PLUGIN_NAME}" - - if [[ -d "$cache_dir" ]]; then - rm -rf "$cache_dir" - plugin_log "success" "Cache cleared" - fi - - return 0 -} - -main() { - local keep_data=false - - # Parse arguments - while [[ $# -gt 0 ]]; do - case "$1" in - --keep-data) - keep_data=true - shift - ;; - *) - shift - ;; - esac - done - - plugin_log "info" "Uninstalling GitHub plugin..." - - # Remove database (unless --keep-data) - remove_database "$keep_data" - - # Remove webhooks - remove_webhooks - - # Remove cache - remove_cache - - # Mark as uninstalled - plugin_mark_uninstalled "$PLUGIN_NAME" - - plugin_log "success" "GitHub plugin uninstalled" - - if [[ "$keep_data" == "true" ]]; then - echo "" - echo "Note: Database tables were preserved. To remove them later:" - echo " nself db query \"DROP TABLE IF EXISTS github_* CASCADE\"" - fi - - return 0 -} - -# Run if executed directly -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi diff --git a/free/github/webhooks/events/default.sh b/free/github/webhooks/events/default.sh deleted file mode 100644 index bb1f9943d..000000000 --- a/free/github/webhooks/events/default.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# GitHub Webhook Handler - Default Handler -# Catches any events without specific handlers - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -handle_default() { - local event_id="$1" - local event_type="$2" - local action="$3" - local payload="$4" - - plugin_log "debug" "Default handler for event: $event_type ($action)" - - # Just log the event - no special processing - # The event is already stored in github_webhook_events by the main handler - - return 0 -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && handle_default "$@" diff --git a/free/github/webhooks/events/issues.sh b/free/github/webhooks/events/issues.sh deleted file mode 100644 index 61bd9677d..000000000 --- a/free/github/webhooks/events/issues.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env bash -# GitHub Webhook Handler - Issues Events - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -handle_issues() { - local event_id="$1" - local action="$2" - local payload="$3" - - plugin_log "info" "Handling issues event: $action" - - local issue repo_id - issue=$(echo "$payload" | jq '.issue') - repo_id=$(echo "$payload" | jq -r '.repository.id') - - local issue_id number title state - issue_id=$(echo "$issue" | jq -r '.id') - number=$(echo "$issue" | jq -r '.number') - title=$(echo "$issue" | jq -r '.title') - state=$(echo "$issue" | jq -r '.state') - - plugin_log "debug" "Issue #$number: $title ($action)" - - # Upsert issue - plugin_db_query "INSERT INTO github_issues ( - id, node_id, repo_id, number, title, body, state, state_reason, - locked, user_login, user_id, labels, assignees, milestone, - comments, html_url, closed_at, created_at, updated_at, synced_at - ) VALUES ( - $(echo "$issue" | jq '.id'), - $(echo "$issue" | jq '.node_id'), - $repo_id, - $(echo "$issue" | jq '.number'), - $(echo "$issue" | jq '.title'), - $(echo "$issue" | jq '.body'), - $(echo "$issue" | jq '.state'), - $(echo "$issue" | jq '.state_reason'), - $(echo "$issue" | jq '.locked'), - $(echo "$issue" | jq '.user.login'), - $(echo "$issue" | jq '.user.id'), - $(echo "$issue" | jq '.labels'), - $(echo "$issue" | jq '.assignees'), - $(echo "$issue" | jq '.milestone'), - $(echo "$issue" | jq '.comments'), - $(echo "$issue" | jq '.html_url'), - $(echo "$issue" | jq '.closed_at'), - $(echo "$issue" | jq '.created_at'), - $(echo "$issue" | jq '.updated_at'), - NOW() - ) ON CONFLICT (id) DO UPDATE SET - title = EXCLUDED.title, - body = EXCLUDED.body, - state = EXCLUDED.state, - state_reason = EXCLUDED.state_reason, - labels = EXCLUDED.labels, - assignees = EXCLUDED.assignees, - comments = EXCLUDED.comments, - closed_at = EXCLUDED.closed_at, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - - case "$action" in - opened) - plugin_log "info" "New issue opened: #$number" - ;; - closed) - plugin_log "info" "Issue closed: #$number" - ;; - reopened) - plugin_log "info" "Issue reopened: #$number" - ;; - esac - - plugin_log "success" "Issues event processed" - return 0 -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && handle_issues "$@" diff --git a/free/github/webhooks/events/pull_request.sh b/free/github/webhooks/events/pull_request.sh deleted file mode 100644 index 7199232b4..000000000 --- a/free/github/webhooks/events/pull_request.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# GitHub Webhook Handler - Pull Request Events - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -handle_pull_request() { - local event_id="$1" - local action="$2" - local payload="$3" - - plugin_log "info" "Handling pull_request event: $action" - - local pr repo_id - pr=$(echo "$payload" | jq '.pull_request') - repo_id=$(echo "$payload" | jq -r '.repository.id') - - local pr_id number title state - pr_id=$(echo "$pr" | jq -r '.id') - number=$(echo "$pr" | jq -r '.number') - title=$(echo "$pr" | jq -r '.title') - state=$(echo "$pr" | jq -r '.state') - - plugin_log "debug" "PR #$number: $title ($action)" - - # Upsert pull request - plugin_db_query "INSERT INTO github_pull_requests ( - id, node_id, repo_id, number, title, body, state, draft, locked, - user_login, user_id, head_ref, head_sha, base_ref, base_sha, - merged, merged_at, merge_commit_sha, labels, assignees, - html_url, created_at, updated_at, synced_at - ) VALUES ( - $(echo "$pr" | jq '.id'), - $(echo "$pr" | jq '.node_id'), - $repo_id, - $(echo "$pr" | jq '.number'), - $(echo "$pr" | jq '.title'), - $(echo "$pr" | jq '.body'), - $(echo "$pr" | jq '.state'), - $(echo "$pr" | jq '.draft'), - $(echo "$pr" | jq '.locked'), - $(echo "$pr" | jq '.user.login'), - $(echo "$pr" | jq '.user.id'), - $(echo "$pr" | jq '.head.ref'), - $(echo "$pr" | jq '.head.sha'), - $(echo "$pr" | jq '.base.ref'), - $(echo "$pr" | jq '.base.sha'), - $(echo "$pr" | jq '.merged'), - $(echo "$pr" | jq '.merged_at'), - $(echo "$pr" | jq '.merge_commit_sha'), - $(echo "$pr" | jq '.labels'), - $(echo "$pr" | jq '.assignees'), - $(echo "$pr" | jq '.html_url'), - $(echo "$pr" | jq '.created_at'), - $(echo "$pr" | jq '.updated_at'), - NOW() - ) ON CONFLICT (id) DO UPDATE SET - title = EXCLUDED.title, - body = EXCLUDED.body, - state = EXCLUDED.state, - draft = EXCLUDED.draft, - merged = EXCLUDED.merged, - merged_at = EXCLUDED.merged_at, - labels = EXCLUDED.labels, - assignees = EXCLUDED.assignees, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - - # Handle specific actions - case "$action" in - opened) - plugin_log "info" "New PR opened: #$number" - ;; - closed) - local merged - merged=$(echo "$pr" | jq -r '.merged') - if [[ "$merged" == "true" ]]; then - plugin_log "info" "PR merged: #$number" - else - plugin_log "info" "PR closed without merge: #$number" - fi - ;; - reopened) - plugin_log "info" "PR reopened: #$number" - ;; - synchronize) - plugin_log "debug" "PR updated with new commits: #$number" - ;; - esac - - plugin_log "success" "Pull request event processed" - return 0 -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && handle_pull_request "$@" diff --git a/free/github/webhooks/events/push.sh b/free/github/webhooks/events/push.sh deleted file mode 100644 index 5bb8ad35c..000000000 --- a/free/github/webhooks/events/push.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# GitHub Webhook Handler - Push Events - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -handle_push() { - local event_id="$1" - local action="$2" - local payload="$3" - - plugin_log "info" "Handling push event" - - # Extract push details - local ref repo_id commits_count - ref=$(echo "$payload" | jq -r '.ref') - repo_id=$(echo "$payload" | jq -r '.repository.id') - commits_count=$(echo "$payload" | jq '.commits | length') - - plugin_log "debug" "Push to $ref with $commits_count commits" - - # Store commits - echo "$payload" | jq -c '.commits[]' 2>/dev/null | while read -r commit; do - local sha message author_name author_email timestamp - sha=$(echo "$commit" | jq -r '.id') - message=$(echo "$commit" | jq -r '.message' | head -1) - author_name=$(echo "$commit" | jq -r '.author.name') - author_email=$(echo "$commit" | jq -r '.author.email') - timestamp=$(echo "$commit" | jq -r '.timestamp') - - plugin_db_query "INSERT INTO github_commits ( - sha, repo_id, message, author_name, author_email, author_date, synced_at - ) VALUES ( - '$sha', - $repo_id, - $(echo "$message" | jq -Rs .), - $(echo "$author_name" | jq -Rs .), - '$author_email', - '$timestamp', - NOW() - ) ON CONFLICT (sha) DO UPDATE SET - message = EXCLUDED.message, - synced_at = NOW()" - done - - # Update repository pushed_at - local pushed_at - pushed_at=$(echo "$payload" | jq -r '.head_commit.timestamp // empty') - if [[ -n "$pushed_at" ]]; then - plugin_db_query "UPDATE github_repositories SET pushed_at = '$pushed_at', synced_at = NOW() WHERE id = $repo_id" - fi - - plugin_log "success" "Push event processed: $commits_count commits" - return 0 -} - -# Run if executed directly -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && handle_push "$@" diff --git a/free/github/webhooks/events/workflow_run.sh b/free/github/webhooks/events/workflow_run.sh deleted file mode 100644 index 6a7d1bd6b..000000000 --- a/free/github/webhooks/events/workflow_run.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -# GitHub Webhook Handler - Workflow Run Events - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -handle_workflow_run() { - local event_id="$1" - local action="$2" - local payload="$3" - - plugin_log "info" "Handling workflow_run event: $action" - - local run repo_id - run=$(echo "$payload" | jq '.workflow_run') - repo_id=$(echo "$payload" | jq -r '.repository.id') - - local run_id workflow_name conclusion - run_id=$(echo "$run" | jq -r '.id') - workflow_name=$(echo "$run" | jq -r '.name') - conclusion=$(echo "$run" | jq -r '.conclusion // "in_progress"') - - plugin_log "debug" "Workflow: $workflow_name ($conclusion)" - - # Upsert workflow run - plugin_db_query "INSERT INTO github_workflow_runs ( - id, node_id, repo_id, workflow_id, workflow_name, name, - head_branch, head_sha, run_number, run_attempt, event, - status, conclusion, actor_login, triggering_actor_login, - html_url, jobs_url, logs_url, run_started_at, created_at, updated_at, synced_at - ) VALUES ( - $(echo "$run" | jq '.id'), - $(echo "$run" | jq '.node_id'), - $repo_id, - $(echo "$run" | jq '.workflow_id'), - $(echo "$run" | jq '.name'), - $(echo "$run" | jq '.display_title'), - $(echo "$run" | jq '.head_branch'), - $(echo "$run" | jq '.head_sha'), - $(echo "$run" | jq '.run_number'), - $(echo "$run" | jq '.run_attempt'), - $(echo "$run" | jq '.event'), - $(echo "$run" | jq '.status'), - $(echo "$run" | jq '.conclusion'), - $(echo "$run" | jq '.actor.login'), - $(echo "$run" | jq '.triggering_actor.login'), - $(echo "$run" | jq '.html_url'), - $(echo "$run" | jq '.jobs_url'), - $(echo "$run" | jq '.logs_url'), - $(echo "$run" | jq '.run_started_at'), - $(echo "$run" | jq '.created_at'), - $(echo "$run" | jq '.updated_at'), - NOW() - ) ON CONFLICT (id) DO UPDATE SET - status = EXCLUDED.status, - conclusion = EXCLUDED.conclusion, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - - case "$action" in - completed) - case "$conclusion" in - success) - plugin_log "success" "Workflow completed successfully: $workflow_name" - ;; - failure) - plugin_log "warning" "Workflow failed: $workflow_name" - ;; - cancelled) - plugin_log "info" "Workflow cancelled: $workflow_name" - ;; - esac - ;; - requested) - plugin_log "info" "Workflow requested: $workflow_name" - ;; - esac - - plugin_log "success" "Workflow run event processed" - return 0 -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && handle_workflow_run "$@" diff --git a/free/github/webhooks/handler.sh b/free/github/webhooks/handler.sh deleted file mode 100644 index 537147a42..000000000 --- a/free/github/webhooks/handler.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env bash -# GitHub Plugin - Webhook Handler -# Receives and processes GitHub webhook events - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -# ============================================================================ -# Signature Verification -# ============================================================================ - -verify_signature() { - local payload="$1" - local signature="$2" - - if [[ -z "${GITHUB_WEBHOOK_SECRET:-}" ]]; then - plugin_log "warning" "GITHUB_WEBHOOK_SECRET not set, skipping verification" - return 0 - fi - - if [[ -z "$signature" ]]; then - plugin_log "error" "No signature provided" - return 1 - fi - - # GitHub uses sha256 HMAC - local expected - expected="sha256=$(echo -n "$payload" | openssl dgst -sha256 -hmac "$GITHUB_WEBHOOK_SECRET" | sed 's/^.* //')" - - if [[ "$signature" != "$expected" ]]; then - plugin_log "error" "Invalid webhook signature" - return 1 - fi - - return 0 -} - -# ============================================================================ -# Event Processing -# ============================================================================ - -process_event() { - local event_id="$1" - local event_type="$2" - local action="$3" - local payload="$4" - - plugin_log "info" "Processing event: $event_type ($action)" - - # Route to specific handler - local handler_script="${PLUGIN_DIR}/webhooks/events/${event_type}.sh" - local default_handler="${PLUGIN_DIR}/webhooks/events/default.sh" - - if [[ -x "$handler_script" ]]; then - bash "$handler_script" "$event_id" "$action" "$payload" - elif [[ -x "$default_handler" ]]; then - bash "$default_handler" "$event_id" "$event_type" "$action" "$payload" - else - plugin_log "warning" "No handler for event: $event_type" - fi - - # Mark as processed - plugin_db_query "UPDATE github_webhook_events SET processed = true, processed_at = NOW() WHERE id = '$event_id'" - - return 0 -} - -# ============================================================================ -# Main Handler -# ============================================================================ - -handle_webhook() { - local delivery_id="$1" - local event_type="$2" - local signature="$3" - local payload="$4" - - # If called with just event ID, load from database - if [[ -n "$delivery_id" ]] && [[ -z "$event_type" ]]; then - local event_data - event_data=$(plugin_db_query "SELECT event, action, data FROM github_webhook_events WHERE id = '$delivery_id'" 2>/dev/null) - - if [[ -n "$event_data" ]]; then - event_type=$(echo "$event_data" | cut -d'|' -f1) - local action=$(echo "$event_data" | cut -d'|' -f2) - payload=$(echo "$event_data" | cut -d'|' -f3) - - process_event "$delivery_id" "$event_type" "$action" "$payload" - return $? - fi - fi - - # Verify signature if secret is set - if ! verify_signature "$payload" "$signature"; then - return 1 - fi - - # Parse payload - local action repo_id repo_name sender - action=$(echo "$payload" | jq -r '.action // empty') - repo_id=$(echo "$payload" | jq -r '.repository.id // empty') - repo_name=$(echo "$payload" | jq -r '.repository.full_name // empty') - sender=$(echo "$payload" | jq -r '.sender.login // empty') - - # Store event - plugin_db_query "INSERT INTO github_webhook_events ( - id, event, action, repo_id, repo_full_name, sender_login, data, received_at - ) VALUES ( - '$delivery_id', - '$event_type', - $(if [[ -n "$action" ]]; then echo "'$action'"; else echo "NULL"; fi), - $(if [[ -n "$repo_id" ]]; then echo "$repo_id"; else echo "NULL"; fi), - $(if [[ -n "$repo_name" ]]; then echo "'$repo_name'"; else echo "NULL"; fi), - $(if [[ -n "$sender" ]]; then echo "'$sender'"; else echo "NULL"; fi), - '$(echo "$payload" | jq -c .)', - NOW() - ) ON CONFLICT (id) DO NOTHING" - - # Process the event - process_event "$delivery_id" "$event_type" "$action" "$payload" - - return 0 -} - -# Handle stdin input (for HTTP handler integration) -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - if [[ $# -ge 1 ]]; then - # Called with arguments - handle_webhook "$@" - else - # Read from environment (set by webhook receiver) - delivery_id="${HTTP_X_GITHUB_DELIVERY:-}" - event_type="${HTTP_X_GITHUB_EVENT:-}" - signature="${HTTP_X_HUB_SIGNATURE_256:-}" - payload=$(cat) - - if [[ -n "$delivery_id" ]] && [[ -n "$event_type" ]]; then - handle_webhook "$delivery_id" "$event_type" "$signature" "$payload" - else - echo "Error: Missing required headers" >&2 - exit 1 - fi - fi -fi diff --git a/free/idme/PLUGIN_SUMMARY.md b/free/idme/PLUGIN_SUMMARY.md index 80c3e01e0..02329116d 100644 --- a/free/idme/PLUGIN_SUMMARY.md +++ b/free/idme/PLUGIN_SUMMARY.md @@ -17,21 +17,8 @@ idme/ ├── README.md # Full documentation (9.7KB) ├── QUICKSTART.md # Quick start guide (2.7KB) ├── .env.example # Environment template -├── install.sh # Database setup (3.4KB) -├── uninstall.sh # Cleanup script (2.2KB) ├── schema/ │ └── tables.sql # Complete schema (9.3KB) -├── actions/ -│ ├── init.sh # OAuth initialization -│ ├── verify.sh # Verification status -│ ├── groups.sh # Group management -│ └── test.sh # Configuration testing -├── webhooks/ -│ ├── handler.sh # Main webhook handler -│ └── events/ -│ ├── default.sh # Default event handler -│ ├── verification.updated.sh -│ └── group.verified.sh └── ts/ ├── package.json # Dependencies ├── tsconfig.json # TypeScript config @@ -79,11 +66,6 @@ Each group has custom badge with icon and color: - PostgreSQL database - Modular architecture -### 5. Shell Scripts -- 4 action scripts (init, verify, groups, test) -- 3 webhook event handlers -- Install/uninstall automation - ## Environment Variables ### Required @@ -160,8 +142,6 @@ Key classes and interfaces: Follows Stripe/GitHub plugin structure: - plugin.json manifest - Schema in schema/tables.sql -- Shell actions in actions/ -- Webhook handlers in webhooks/ - TypeScript in ts/src/ ## Production Checklist @@ -205,12 +185,6 @@ nself plugin idme test - `pg` - PostgreSQL client - `dotenv` - Environment variables -### Shell -- `bash` - Shell scripts -- `curl` - HTTP requests -- `openssl` - Signature generation -- `jq` - JSON parsing (optional) - ## Documentation 1. **README.md** (9.7KB) - Complete documentation diff --git a/free/idme/QUICKSTART.md b/free/idme/QUICKSTART.md index 337eedca7..3110c64ba 100644 --- a/free/idme/QUICKSTART.md +++ b/free/idme/QUICKSTART.md @@ -21,8 +21,7 @@ Get up and running with ID.me authentication in 5 minutes. ## Step 2: Install Plugin ```bash -cd ~/Sites/nself-plugins/plugins/idme -./install.sh +nself plugin install idme ``` ## Step 3: Configure diff --git a/free/idme/README.md b/free/idme/README.md index 71d01bd88..9be0346a0 100644 --- a/free/idme/README.md +++ b/free/idme/README.md @@ -31,8 +31,7 @@ OAuth authentication with government-grade identity verification for military, v 2. **Install the plugin** ```bash - cd plugins/idme - ./install.sh + nself plugin install idme ``` 3. **Configure environment variables** @@ -418,7 +417,7 @@ nself plugin idme test ### Database Errors **Problem**: "relation np_idme_verifications does not exist" -- **Solution**: Run `./install.sh` to create tables +- **Solution**: Start the plugin (it applies its migrations at boot) or run `nself plugin idme init` **Problem**: "Connection refused" - **Solution**: Check `DATABASE_URL` is correct and PostgreSQL is running diff --git a/free/idme/actions/groups.sh b/free/idme/actions/groups.sh deleted file mode 100755 index 1e5ba75d8..000000000 --- a/free/idme/actions/groups.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/bin/bash -# ============================================================================= -# ID.me Groups Action -# List and manage verified groups -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Functions -# ============================================================================= - -list_all_groups() { - plugin_info "All Verification Groups" - printf "\n" - - plugin_db_query " - SELECT * FROM idme_group_summary - " || plugin_error "Failed to fetch groups" -} - -list_user_groups() { - local user_email="$1" - - plugin_info "Groups for: $user_email" - printf "\n" - - plugin_db_query " - SELECT - g.group_type, - g.group_name, - g.verified, - g.verified_at, - g.affiliation, - g.rank, - g.status - FROM np_idme_groups g - JOIN np_idme_verifications v ON g.verification_id = v.id - WHERE v.email = '$user_email' - ORDER BY g.verified_at DESC - " || plugin_error "Failed to fetch user groups" -} - -list_group_type() { - local group_type="$1" - - plugin_info "Users verified as: $group_type" - printf "\n" - - plugin_db_query " - SELECT - v.email, - v.first_name || ' ' || v.last_name as name, - g.verified_at, - g.affiliation, - g.rank - FROM np_idme_groups g - JOIN np_idme_verifications v ON g.verification_id = v.id - WHERE g.group_type = '$group_type' AND g.verified = TRUE - ORDER BY g.verified_at DESC - " || plugin_error "Failed to fetch group members" -} - -show_group_types() { - plugin_info "Available Group Types" - printf "\n" - printf " military - Active duty military personnel\n" - printf " veteran - Military veterans\n" - printf " first_responder - First responders (police, fire, EMT)\n" - printf " government - Government employees\n" - printf " teacher - Teachers and educators\n" - printf " student - Students\n" - printf " nurse - Nurses and healthcare workers\n" - printf "\n" -} - -# ============================================================================= -# Main -# ============================================================================= - -main() { - case "${1:-}" in - -h|--help|help) - printf "Usage: nself plugin idme groups [command] [args]\n\n" - printf "Commands:\n" - printf " list List all groups with counts\n" - printf " user List groups for a specific user\n" - printf " type List all users in a group type\n" - printf " types Show available group types\n" - printf "\n" - ;; - list) - list_all_groups - ;; - user) - if [[ -z "${2:-}" ]]; then - plugin_error "Email required" - printf "Usage: nself plugin idme groups user \n" - return 1 - fi - list_user_groups "$2" - ;; - type) - if [[ -z "${2:-}" ]]; then - plugin_error "Group type required" - printf "Usage: nself plugin idme groups type \n\n" - show_group_types - return 1 - fi - list_group_type "$2" - ;; - types) - show_group_types - ;; - *) - list_all_groups - ;; - esac -} - -main "$@" diff --git a/free/idme/actions/init.sh b/free/idme/actions/init.sh deleted file mode 100755 index 6c4511b32..000000000 --- a/free/idme/actions/init.sh +++ /dev/null @@ -1,129 +0,0 @@ -#!/bin/bash -# ============================================================================= -# ID.me Init Action -# Initialize OAuth configuration and display authorization URL -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Configuration -# ============================================================================= - -IDME_CLIENT_ID="${IDME_CLIENT_ID:-}" -IDME_REDIRECT_URI="${IDME_REDIRECT_URI:-}" -IDME_SCOPES="${IDME_SCOPES:-openid,email,profile}" -IDME_SANDBOX="${IDME_SANDBOX:-false}" - -# Determine base URL -if [[ "$IDME_SANDBOX" == "true" ]]; then - IDME_BASE_URL="https://api.idmelabs.com" -else - IDME_BASE_URL="https://api.id.me" -fi - -# ============================================================================= -# Functions -# ============================================================================= - -show_config() { - plugin_info "ID.me OAuth Configuration" - printf "\n" - printf "Environment:\n" - printf " Mode: %s\n" "$([ "$IDME_SANDBOX" == "true" ] && echo "Sandbox" || echo "Production")" - printf " Base URL: %s\n" "$IDME_BASE_URL" - printf " Client ID: %s\n" "${IDME_CLIENT_ID:0:8}..." - printf " Redirect URI: %s\n" "$IDME_REDIRECT_URI" - printf " Scopes: %s\n" "$IDME_SCOPES" - printf "\n" -} - -generate_auth_url() { - # Generate random state for CSRF protection - local state - state=$(openssl rand -hex 16) - - # URL encode scopes - local encoded_scopes - encoded_scopes=$(echo -n "$IDME_SCOPES" | sed 's/ /%20/g' | sed 's/,/%2C/g') - - # Build authorization URL - local auth_url - auth_url="${IDME_BASE_URL}/oauth/authorize" - auth_url="${auth_url}?client_id=${IDME_CLIENT_ID}" - auth_url="${auth_url}&redirect_uri=${IDME_REDIRECT_URI}" - auth_url="${auth_url}&response_type=code" - auth_url="${auth_url}&scope=${encoded_scopes}" - auth_url="${auth_url}&state=${state}" - - printf "Authorization URL:\n\n" - printf "%s\n\n" "$auth_url" - printf "State (save for CSRF validation): %s\n\n" "$state" -} - -show_groups() { - plugin_info "Available Verification Groups" - printf "\n" - printf "You can request verification for the following groups by adding them to IDME_SCOPES:\n\n" - printf " military - Active duty military personnel\n" - printf " veteran - Military veterans\n" - printf " first_responder - First responders (police, fire, EMT)\n" - printf " government - Government employees\n" - printf " teacher - Teachers and educators\n" - printf " student - Students\n" - printf " nurse - Nurses and healthcare workers\n" - printf "\n" - printf "Example: IDME_SCOPES=openid,email,profile,military,veteran\n" - printf "\n" -} - -# ============================================================================= -# Main -# ============================================================================= - -main() { - # Check required variables - if [[ -z "$IDME_CLIENT_ID" ]] || [[ -z "$IDME_REDIRECT_URI" ]]; then - plugin_error "Missing required environment variables" - printf "\n" - printf "Please set the following in your .env file:\n" - printf " IDME_CLIENT_ID=your_client_id\n" - printf " IDME_CLIENT_SECRET=your_client_secret\n" - printf " IDME_REDIRECT_URI=https://your-domain.com/callback/idme\n" - printf "\n" - return 1 - fi - - case "${1:-}" in - -h|--help|help) - printf "Usage: nself plugin idme init [command]\n\n" - printf "Commands:\n" - printf " config Show current configuration\n" - printf " auth Generate authorization URL\n" - printf " groups Show available verification groups\n" - printf "\n" - ;; - config) - show_config - ;; - auth) - show_config - generate_auth_url - ;; - groups) - show_groups - ;; - *) - show_config - generate_auth_url - show_groups - ;; - esac -} - -main "$@" diff --git a/free/idme/actions/test.sh b/free/idme/actions/test.sh deleted file mode 100755 index 85b3a654a..000000000 --- a/free/idme/actions/test.sh +++ /dev/null @@ -1,182 +0,0 @@ -#!/bin/bash -# ============================================================================= -# ID.me Test Action -# Test OAuth connection and API access -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Configuration -# ============================================================================= - -IDME_CLIENT_ID="${IDME_CLIENT_ID:-}" -IDME_CLIENT_SECRET="${IDME_CLIENT_SECRET:-}" -IDME_SANDBOX="${IDME_SANDBOX:-false}" - -# Determine base URL -if [[ "$IDME_SANDBOX" == "true" ]]; then - IDME_BASE_URL="https://api.idmelabs.com" -else - IDME_BASE_URL="https://api.id.me" -fi - -# ============================================================================= -# Tests -# ============================================================================= - -test_config() { - plugin_info "Testing configuration..." - - local errors=0 - - # Check client ID - if [[ -z "$IDME_CLIENT_ID" ]]; then - plugin_error "IDME_CLIENT_ID not set" - errors=$((errors + 1)) - else - plugin_success "IDME_CLIENT_ID is set" - fi - - # Check client secret - if [[ -z "$IDME_CLIENT_SECRET" ]]; then - plugin_error "IDME_CLIENT_SECRET not set" - errors=$((errors + 1)) - else - plugin_success "IDME_CLIENT_SECRET is set" - fi - - # Check redirect URI - if [[ -z "${IDME_REDIRECT_URI:-}" ]]; then - plugin_warn "IDME_REDIRECT_URI not set" - else - plugin_success "IDME_REDIRECT_URI is set" - fi - - printf "\n" - - if [[ $errors -gt 0 ]]; then - plugin_error "Configuration test failed" - return 1 - fi - - plugin_success "Configuration test passed" - return 0 -} - -test_database() { - plugin_info "Testing database connection..." - - # Check if tables exist - local tables=("np_idme_verifications" "np_idme_groups" "np_idme_badges" "np_idme_attributes") - local errors=0 - - for table in "${tables[@]}"; do - local exists - exists=$(plugin_db_query " - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = '$table' - ) - " | grep -o 't\|f' || echo "f") - - if [[ "$exists" == "t" ]]; then - plugin_success "Table $table exists" - else - plugin_error "Table $table does not exist" - errors=$((errors + 1)) - fi - done - - printf "\n" - - if [[ $errors -gt 0 ]]; then - plugin_error "Database test failed" - return 1 - fi - - plugin_success "Database test passed" - return 0 -} - -test_api() { - plugin_info "Testing API connectivity..." - - # Try to reach the ID.me API - local response - response=$(curl -s -o /dev/null -w "%{http_code}" "${IDME_BASE_URL}/" || echo "000") - - if [[ "$response" == "000" ]]; then - plugin_error "Cannot reach ID.me API at: $IDME_BASE_URL" - return 1 - fi - - plugin_success "API is reachable (HTTP $response)" - printf " Base URL: %s\n" "$IDME_BASE_URL" - printf " Mode: %s\n" "$([ "$IDME_SANDBOX" == "true" ] && echo "Sandbox" || echo "Production")" - printf "\n" - - plugin_success "API connectivity test passed" - return 0 -} - -run_all_tests() { - plugin_info "Running all tests..." - printf "\n" - - local failed=0 - - test_config || failed=$((failed + 1)) - printf "\n" - - test_database || failed=$((failed + 1)) - printf "\n" - - test_api || failed=$((failed + 1)) - printf "\n" - - if [[ $failed -eq 0 ]]; then - plugin_success "All tests passed!" - return 0 - else - plugin_error "$failed test(s) failed" - return 1 - fi -} - -# ============================================================================= -# Main -# ============================================================================= - -main() { - case "${1:-}" in - -h|--help|help) - printf "Usage: nself plugin idme test [command]\n\n" - printf "Commands:\n" - printf " config Test configuration\n" - printf " database Test database setup\n" - printf " api Test API connectivity\n" - printf " all Run all tests (default)\n" - printf "\n" - ;; - config) - test_config - ;; - database) - test_database - ;; - api) - test_api - ;; - all|*) - run_all_tests - ;; - esac -} - -main "$@" diff --git a/free/idme/actions/verify.sh b/free/idme/actions/verify.sh deleted file mode 100755 index 425d0dd71..000000000 --- a/free/idme/actions/verify.sh +++ /dev/null @@ -1,147 +0,0 @@ -#!/bin/bash -# ============================================================================= -# ID.me Verify Action -# Check verification status for a user -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Functions -# ============================================================================= - -verify_user() { - local user_email="$1" - - plugin_info "Checking verification status for: $user_email" - printf "\n" - - # Query verification status - local result - result=$(plugin_db_query " - SELECT - v.verified, - v.verified_at, - v.first_name, - v.last_name, - COUNT(g.id) as groups_count - FROM np_idme_verifications v - LEFT JOIN np_idme_groups g ON v.id = g.verification_id AND g.verified = TRUE - WHERE v.email = '$user_email' - GROUP BY v.id, v.verified, v.verified_at, v.first_name, v.last_name - " || echo "") - - if [[ -z "$result" ]]; then - plugin_warn "No verification found for: $user_email" - return 1 - fi - - # Parse result (simplified - in production use proper JSON parsing) - local verified=$(echo "$result" | grep -o 't\|f' | head -1) - local groups_count=$(echo "$result" | grep -o '[0-9]*$') - - if [[ "$verified" == "t" ]]; then - plugin_success "User is verified" - printf " Groups verified: %s\n" "$groups_count" - else - plugin_warn "User is not verified" - fi - - # Show groups - if [[ "$groups_count" -gt 0 ]]; then - printf "\nVerified groups:\n" - plugin_db_query " - SELECT group_type, group_name, verified_at - FROM np_idme_groups g - JOIN np_idme_verifications v ON g.verification_id = v.id - WHERE v.email = '$user_email' AND g.verified = TRUE - ORDER BY g.verified_at DESC - " || true - fi -} - -list_verifications() { - local limit="${1:-10}" - - plugin_info "Recent verifications (last $limit)" - printf "\n" - - plugin_db_query " - SELECT - v.email, - v.first_name || ' ' || v.last_name as name, - v.verified_at, - COUNT(g.id) as groups - FROM np_idme_verifications v - LEFT JOIN np_idme_groups g ON v.id = g.verification_id AND g.verified = TRUE - WHERE v.verified = TRUE - GROUP BY v.id, v.email, v.first_name, v.last_name, v.verified_at - ORDER BY v.verified_at DESC - LIMIT $limit - " || plugin_error "Failed to fetch verifications" -} - -show_stats() { - plugin_info "Verification Statistics" - printf "\n" - - # Total verifications - local total - total=$(plugin_db_query "SELECT COUNT(*) FROM np_idme_verifications WHERE verified = TRUE" | tr -d '[:space:]') - printf "Total verified users: %s\n" "$total" - - # Groups breakdown - printf "\nGroups breakdown:\n" - plugin_db_query " - SELECT - group_type, - COUNT(*) as count - FROM np_idme_groups - WHERE verified = TRUE - GROUP BY group_type - ORDER BY count DESC - " || true -} - -# ============================================================================= -# Main -# ============================================================================= - -main() { - case "${1:-}" in - -h|--help|help) - printf "Usage: nself plugin idme verify [command] [args]\n\n" - printf "Commands:\n" - printf " user Check verification status for a user\n" - printf " list [limit] List recent verifications (default: 10)\n" - printf " stats Show verification statistics\n" - printf "\n" - ;; - user) - if [[ -z "${2:-}" ]]; then - plugin_error "Email required" - printf "Usage: nself plugin idme verify user \n" - return 1 - fi - verify_user "$2" - ;; - list) - list_verifications "${2:-10}" - ;; - stats) - show_stats - ;; - *) - show_stats - printf "\n" - list_verifications 5 - ;; - esac -} - -main "$@" diff --git a/free/idme/install.sh b/free/idme/install.sh deleted file mode 100755 index 976841d47..000000000 --- a/free/idme/install.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/bin/bash -# ============================================================================= -# ID.me Plugin Installer -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" -source "${SHARED_DIR}/schema-sync.sh" - -# ============================================================================= -# Installation -# ============================================================================= - -install_idme_plugin() { - plugin_info "Installing ID.me plugin..." - - # Check required environment variables - if ! plugin_check_env "idme" "IDME_CLIENT_ID"; then - plugin_warn "IDME_CLIENT_ID not set. You'll need to configure it before using the plugin." - plugin_info "Add to your .env: IDME_CLIENT_ID=your_client_id" - fi - - if ! plugin_check_env "idme" "IDME_CLIENT_SECRET"; then - plugin_warn "IDME_CLIENT_SECRET not set. You'll need to configure it before using the plugin." - plugin_info "Add to your .env: IDME_CLIENT_SECRET=your_client_secret" - fi - - if ! plugin_check_env "idme" "IDME_REDIRECT_URI"; then - plugin_warn "IDME_REDIRECT_URI not set. You'll need to configure it before using the plugin." - plugin_info "Add to your .env: IDME_REDIRECT_URI=https://your-domain.com/callback/idme" - fi - - # Apply database schema - plugin_info "Applying database schema..." - - # Ensure migrations table exists - schema_ensure_migrations_table - - # The tables are created (and the old idme_* tables renamed to np_idme_*) by the plugin's own boot migrations - # (migrations/000_np_prefix_and_tables.sql). Creating them here first would leave the old rows orphaned in idme_*. - # schema/tables.sql stays as the reference DDL; it is not applied by this script. - - # Apply migrations - if [[ -d "${PLUGIN_DIR}/schema/migrations" ]]; then - for migration in "${PLUGIN_DIR}/schema/migrations"/*.sql; do - [[ ! -f "$migration" ]] && continue - - local migration_name - migration_name=$(basename "$migration" .sql) - - if ! schema_migration_applied "idme" "$migration_name"; then - plugin_info "Applying migration: $migration_name" - plugin_db_exec_file "$migration" - schema_record_migration "idme" "$migration_name" - fi - done - fi - - # Create cache and log directories - mkdir -p "${HOME}/.nself/cache/plugins/idme" - mkdir -p "${HOME}/.nself/logs/plugins/idme" - - # Register webhook endpoint (if webhook secret is configured) - if [[ -n "${IDME_WEBHOOK_SECRET:-}" ]]; then - plugin_info "Webhook endpoint configured" - else - plugin_info "To enable webhooks, set IDME_WEBHOOK_SECRET in your .env" - fi - - plugin_success "ID.me plugin installed successfully!" - - printf "\n" - printf "Next steps:\n" - printf " 1. Register your application at https://developers.id.me\n" - printf " 2. Add OAuth credentials to your .env file:\n" - printf " IDME_CLIENT_ID=your_client_id\n" - printf " IDME_CLIENT_SECRET=your_client_secret\n" - printf " IDME_REDIRECT_URI=https://your-domain.com/callback/idme\n" - printf " 3. (Optional) Enable sandbox mode for testing:\n" - printf " IDME_SANDBOX=true\n" - printf " 4. (Optional) Customize scopes:\n" - printf " IDME_SCOPES=openid,email,profile,military,veteran\n" - printf " 5. Test the connection:\n" - printf " nself plugin idme test\n" - printf "\n" -} - -# Run installation -install_idme_plugin diff --git a/free/idme/schema/tables.sql b/free/idme/schema/tables.sql index 75f113136..755fb6cc8 100644 --- a/free/idme/schema/tables.sql +++ b/free/idme/schema/tables.sql @@ -1,7 +1,7 @@ -- ============================================================================= -- ID.me Plugin Schema -- Tables for OAuth authentication and identity verification --- Reference copy. The plugin applies migrations/000_np_prefix_and_tables.sql itself at boot; install.sh does not run this file. +-- Reference copy. The plugin applies migrations/000_np_prefix_and_tables.sql itself at boot; nothing runs this file. -- ============================================================================= -- Enable UUID extension if not already enabled diff --git a/free/idme/uninstall.sh b/free/idme/uninstall.sh deleted file mode 100755 index 2e1900038..000000000 --- a/free/idme/uninstall.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash -# ============================================================================= -# ID.me Plugin Uninstaller -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Uninstallation -# ============================================================================= - -uninstall_idme_plugin() { - plugin_info "Uninstalling ID.me plugin..." - - # Prompt for data deletion - plugin_warn "This will remove all ID.me verification data from the database." - printf "Are you sure you want to continue? (yes/no): " - read -r confirm - - if [[ "$confirm" != "yes" ]]; then - plugin_info "Uninstall cancelled." - return 0 - fi - - # Drop tables in reverse dependency order - plugin_info "Removing database tables..." - - plugin_db_query "DROP VIEW IF EXISTS idme_recent_verifications CASCADE;" || true - plugin_db_query "DROP VIEW IF EXISTS idme_group_summary CASCADE;" || true - plugin_db_query "DROP VIEW IF EXISTS idme_verified_users CASCADE;" || true - - plugin_db_query "DROP TABLE IF EXISTS np_idme_webhook_events CASCADE;" || true - plugin_db_query "DROP TABLE IF EXISTS np_idme_attributes CASCADE;" || true - plugin_db_query "DROP TABLE IF EXISTS np_idme_badges CASCADE;" || true - plugin_db_query "DROP TABLE IF EXISTS np_idme_groups CASCADE;" || true - plugin_db_query "DROP TABLE IF EXISTS np_idme_verifications CASCADE;" || true - - # Drop triggers and functions - plugin_db_query "DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE;" || true - - # Remove migration records - plugin_db_query "DELETE FROM plugin_migrations WHERE plugin_name = 'idme';" || true - - # Remove cache and logs (optional) - printf "Remove cached data and logs? (yes/no): " - read -r remove_cache - if [[ "$remove_cache" == "yes" ]]; then - rm -rf "${HOME}/.nself/cache/plugins/idme" - rm -rf "${HOME}/.nself/logs/plugins/idme" - plugin_info "Cache and logs removed" - fi - - plugin_success "ID.me plugin uninstalled successfully!" -} - -# Run uninstallation -uninstall_idme_plugin diff --git a/free/idme/webhooks/events/default.sh b/free/idme/webhooks/events/default.sh deleted file mode 100755 index 7541beb7f..000000000 --- a/free/idme/webhooks/events/default.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -# ============================================================================= -# ID.me Default Webhook Event Handler -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Handler -# ============================================================================= - -EVENT_TYPE="${1:-unknown}" -PAYLOAD="${2:-{}}" - -plugin_info "Default handler for event: $EVENT_TYPE" - -# Just log the event -plugin_info "Payload: $PAYLOAD" - -# Store in database if not already stored -EVENT_ID=$(echo "$PAYLOAD" | grep -o '"id":"[^"]*"' | sed 's/"id":"\([^"]*\)"/\1/' || echo "unknown-$(date +%s)") - -plugin_db_query " - INSERT INTO np_idme_webhook_events (event_id, event_type, payload, received_at) - VALUES ('$EVENT_ID', '$EVENT_TYPE', '$PAYLOAD'::jsonb, NOW()) - ON CONFLICT (event_id) DO NOTHING -" || true - -plugin_success "Event logged: $EVENT_TYPE" diff --git a/free/idme/webhooks/events/group.verified.sh b/free/idme/webhooks/events/group.verified.sh deleted file mode 100755 index 06aa2f377..000000000 --- a/free/idme/webhooks/events/group.verified.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash -# ============================================================================= -# ID.me Group Verified Handler -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Handler -# ============================================================================= - -PAYLOAD="${1:-{}}" - -plugin_info "Processing group verification" - -# Extract data -IDME_USER_ID=$(echo "$PAYLOAD" | grep -o '"user_id":"[^"]*"' | sed 's/"user_id":"\([^"]*\)"/\1/') -GROUP_TYPE=$(echo "$PAYLOAD" | grep -o '"group_type":"[^"]*"' | sed 's/"group_type":"\([^"]*\)"/\1/') -GROUP_NAME=$(echo "$PAYLOAD" | grep -o '"group_name":"[^"]*"' | sed 's/"group_name":"\([^"]*\)"/\1/') - -# Get verification ID -VERIFICATION_ID=$(plugin_db_query " - SELECT id FROM np_idme_verifications WHERE idme_user_id = '$IDME_USER_ID' -" | tr -d '[:space:]') - -if [[ -z "$VERIFICATION_ID" ]]; then - plugin_error "Verification not found for user: $IDME_USER_ID" - return 1 -fi - -# Get user ID -USER_ID=$(plugin_db_query " - SELECT user_id FROM np_idme_verifications WHERE id = '$VERIFICATION_ID' -" | tr -d '[:space:]') - -# Update or insert group -plugin_db_query " - INSERT INTO np_idme_groups (verification_id, user_id, group_type, group_name, verified, verified_at) - VALUES ('$VERIFICATION_ID', '$USER_ID', '$GROUP_TYPE', '$GROUP_NAME', TRUE, NOW()) - ON CONFLICT (verification_id, group_type) - DO UPDATE SET verified = TRUE, verified_at = NOW(), updated_at = NOW() -" || plugin_error "Failed to update group" - -# Create badge -plugin_db_query " - INSERT INTO np_idme_badges (verification_id, user_id, badge_type, badge_name, verified_at, active) - VALUES ('$VERIFICATION_ID', '$USER_ID', '$GROUP_TYPE', '$GROUP_NAME', NOW(), TRUE) - ON CONFLICT (verification_id, badge_type) - DO UPDATE SET active = TRUE, verified_at = NOW(), updated_at = NOW() -" || plugin_error "Failed to create badge" - -plugin_success "Group verified: $GROUP_TYPE for user $IDME_USER_ID" diff --git a/free/idme/webhooks/events/verification.updated.sh b/free/idme/webhooks/events/verification.updated.sh deleted file mode 100755 index 587570a5a..000000000 --- a/free/idme/webhooks/events/verification.updated.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -# ============================================================================= -# ID.me Verification Updated Handler -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Handler -# ============================================================================= - -PAYLOAD="${1:-{}}" - -plugin_info "Processing verification update" - -# Extract verification data -IDME_USER_ID=$(echo "$PAYLOAD" | grep -o '"user_id":"[^"]*"' | sed 's/"user_id":"\([^"]*\)"/\1/') -EMAIL=$(echo "$PAYLOAD" | grep -o '"email":"[^"]*"' | sed 's/"email":"\([^"]*\)"/\1/') -VERIFIED=$(echo "$PAYLOAD" | grep -o '"verified":[a-z]*' | sed 's/"verified"://') - -# Update verification record -plugin_db_query " - UPDATE np_idme_verifications - SET - verified = $VERIFIED, - verified_at = CASE WHEN $VERIFIED THEN NOW() ELSE NULL END, - last_synced_at = NOW(), - updated_at = NOW() - WHERE idme_user_id = '$IDME_USER_ID' -" || plugin_error "Failed to update verification" - -plugin_success "Verification updated for: $EMAIL" diff --git a/free/idme/webhooks/handler.sh b/free/idme/webhooks/handler.sh deleted file mode 100755 index c5b3fd68f..000000000 --- a/free/idme/webhooks/handler.sh +++ /dev/null @@ -1,116 +0,0 @@ -#!/bin/bash -# ============================================================================= -# ID.me Webhook Handler -# Process incoming webhooks from ID.me -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Configuration -# ============================================================================= - -IDME_WEBHOOK_SECRET="${IDME_WEBHOOK_SECRET:-}" - -# ============================================================================= -# Webhook Processing -# ============================================================================= - -verify_signature() { - local payload="$1" - local signature="$2" - - if [[ -z "$IDME_WEBHOOK_SECRET" ]]; then - plugin_warn "IDME_WEBHOOK_SECRET not set - skipping signature verification" - return 0 - fi - - # Compute expected signature - local expected - expected=$(echo -n "$payload" | openssl dgst -sha256 -hmac "$IDME_WEBHOOK_SECRET" | cut -d' ' -f2) - - if [[ "$signature" != "$expected" ]]; then - plugin_error "Invalid webhook signature" - return 1 - fi - - return 0 -} - -process_webhook() { - local event_type="$1" - local payload="$2" - - plugin_info "Processing webhook: $event_type" - - # Extract common fields - local event_id - event_id=$(echo "$payload" | grep -o '"id":"[^"]*"' | head -1 | sed 's/"id":"\([^"]*\)"/\1/') - - local user_id - user_id=$(echo "$payload" | grep -o '"user_id":"[^"]*"' | head -1 | sed 's/"user_id":"\([^"]*\)"/\1/' || echo "") - - # Store webhook event - plugin_db_query " - INSERT INTO np_idme_webhook_events (event_id, event_type, user_id, payload, received_at) - VALUES ('$event_id', '$event_type', $([ -n "$user_id" ] && echo "'$user_id'" || echo "NULL"), '$payload'::jsonb, NOW()) - ON CONFLICT (event_id) DO UPDATE SET retry_count = np_idme_webhook_events.retry_count + 1 - " || plugin_error "Failed to store webhook event" - - # Route to specific handler - local handler="${PLUGIN_DIR}/webhooks/events/${event_type}.sh" - - if [[ -f "$handler" ]]; then - bash "$handler" "$payload" || plugin_error "Handler failed: $event_type" - else - # Use default handler - bash "${PLUGIN_DIR}/webhooks/events/default.sh" "$event_type" "$payload" - fi - - # Mark as processed - plugin_db_query " - UPDATE np_idme_webhook_events - SET processed = TRUE, processed_at = NOW() - WHERE event_id = '$event_id' - " || true - - plugin_success "Webhook processed: $event_type" -} - -# ============================================================================= -# Main -# ============================================================================= - -main() { - # Read from stdin if no args provided (for HTTP server integration) - if [[ $# -eq 0 ]]; then - local payload - payload=$(cat) - - local event_type - event_type=$(echo "$payload" | grep -o '"type":"[^"]*"' | sed 's/"type":"\([^"]*\)"/\1/') - - local signature="${HTTP_X_IDME_SIGNATURE:-}" - - # Verify signature - if ! verify_signature "$payload" "$signature"; then - plugin_error "Signature verification failed" - return 1 - fi - - process_webhook "$event_type" "$payload" - else - # Manual invocation for testing - local event_type="$1" - local payload="$2" - - process_webhook "$event_type" "$payload" - fi -} - -main "$@" diff --git a/free/jobs/PLUGIN-SUMMARY.md b/free/jobs/PLUGIN-SUMMARY.md index 6df7ef7bc..cd50fa7bf 100644 --- a/free/jobs/PLUGIN-SUMMARY.md +++ b/free/jobs/PLUGIN-SUMMARY.md @@ -29,17 +29,8 @@ jobs/ ├── plugin.json # Plugin manifest (port 3105, category: infrastructure) ├── README.md # Comprehensive documentation ├── .env.example # Environment variable template -├── install.sh # Installation script (applies schema, checks Redis) -├── uninstall.sh # Cleanup script (with Redis clearing) ├── schema/ │ └── tables.sql # Complete database schema (4 tables, 5 views, 3 functions) -├── actions/ -│ ├── init.sh # Verify Redis, DB, configuration -│ ├── server.sh # Start BullBoard dashboard -│ ├── worker.sh # Start job worker process -│ ├── stats.sh # View statistics and metrics -│ ├── retry.sh # Retry failed jobs -│ └── schedule.sh # Manage cron schedules └── ts/ ├── package.json # Dependencies (bullmq, @bull-board, fastify, ioredis, pg) ├── tsconfig.json # TypeScript configuration @@ -280,8 +271,7 @@ Built-in schedules clean old jobs automatically: 2. **Test Installation**: ```bash - cd ~/Sites/nself-plugins/plugins/jobs - ./install.sh + nself plugin install jobs ``` 3. **Start Services**: diff --git a/free/jobs/actions/init.sh b/free/jobs/actions/init.sh deleted file mode 100755 index 2b872fb51..000000000 --- a/free/jobs/actions/init.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Jobs Init Action -# Initialize jobs infrastructure (verify Redis, database, create queues) -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Initialization -# ============================================================================= - -init_jobs() { - plugin_info "Initializing Jobs infrastructure..." - - # Check Redis connection - plugin_info "Checking Redis connection..." - if ! command -v redis-cli &> /dev/null; then - plugin_error "redis-cli not found. Please install Redis." - return 1 - fi - - local redis_url="${JOBS_REDIS_URL:-redis://localhost:6379}" - local redis_host=$(echo "$redis_url" | sed -E 's|redis://([^:]+).*|\1|') - local redis_port=$(echo "$redis_url" | sed -E 's|redis://[^:]+:([0-9]+).*|\1|') - - if ! redis-cli -h "${redis_host:-localhost}" -p "${redis_port:-6379}" ping &>/dev/null; then - plugin_error "Cannot connect to Redis at ${redis_url}" - return 1 - fi - - plugin_success "Redis connection OK" - - # Check database connection - plugin_info "Checking database connection..." - if ! plugin_db_query "SELECT 1;" &>/dev/null; then - plugin_error "Cannot connect to database" - return 1 - fi - - plugin_success "Database connection OK" - - # Verify tables exist - plugin_info "Verifying database schema..." - local tables=("jobs" "job_results" "job_failures" "job_schedules") - for table in "${tables[@]}"; do - if ! plugin_db_query "SELECT 1 FROM information_schema.tables WHERE table_name = '$table';" | grep -q "1"; then - plugin_error "Table '$table' not found. Run 'nself plugin jobs install' first." - return 1 - fi - done - - plugin_success "Database schema OK" - - # Show queue configuration - printf "\n" - printf "Queue Configuration:\n" - printf " Redis URL: %s\n" "${JOBS_REDIS_URL}" - printf " Default concurrency: %s\n" "${JOBS_DEFAULT_CONCURRENCY:-5}" - printf " Max retry attempts: %s\n" "${JOBS_RETRY_ATTEMPTS:-3}" - printf " Retry delay: %s ms\n" "${JOBS_RETRY_DELAY:-5000}" - printf " Job timeout: %s ms\n" "${JOBS_JOB_TIMEOUT:-60000}" - printf "\n" - - # Show queues - plugin_info "Available queues:" - printf " - default (concurrency: %s)\n" "${JOBS_DEFAULT_CONCURRENCY:-5}" - printf " - high-priority (concurrency: 3)\n" - printf " - low-priority (concurrency: 10)\n" - printf "\n" - - # Show stats - plugin_info "Current statistics:" - plugin_db_query " - SELECT - COALESCE(queue_name, 'TOTAL') AS queue, - SUM(waiting) AS waiting, - SUM(active) AS active, - SUM(completed) AS completed, - SUM(failed) AS failed - FROM queue_stats - GROUP BY ROLLUP(queue_name) - ORDER BY queue_name NULLS LAST; - " | column -t -s '|' - - printf "\n" - plugin_success "Jobs infrastructure ready!" - printf "\n" - printf "Next steps:\n" - printf " - Start dashboard: nself plugin jobs server\n" - printf " - Start worker: nself plugin jobs worker\n" - printf " - View stats: nself plugin jobs stats\n" - printf "\n" -} - -# Show help -show_help() { - printf "Usage: nself plugin jobs init\n\n" - printf "Initialize jobs infrastructure and verify configuration.\n\n" - printf "Checks:\n" - printf " - Redis connectivity\n" - printf " - Database connectivity\n" - printf " - Database schema\n" - printf " - Queue configuration\n\n" - printf "Environment:\n" - printf " JOBS_REDIS_URL Redis connection URL (required)\n" - printf " JOBS_DEFAULT_CONCURRENCY Default queue concurrency (default: 5)\n" - printf " JOBS_RETRY_ATTEMPTS Max retry attempts (default: 3)\n" -} - -# Parse arguments -case "${1:-}" in - -h|--help|help) - show_help - ;; - *) - init_jobs - ;; -esac diff --git a/free/jobs/actions/retry.sh b/free/jobs/actions/retry.sh deleted file mode 100755 index 5f0df599c..000000000 --- a/free/jobs/actions/retry.sh +++ /dev/null @@ -1,235 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Jobs Retry Action -# Retry failed jobs -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Retry Failed Jobs -# ============================================================================= - -retry_failed() { - local queue_name="${1:-}" - local job_type="${2:-}" - local limit="${3:-10}" - - plugin_info "Retrying failed jobs..." - - # Build WHERE clause - local where_clause="status = 'failed' AND retry_count < max_retries" - if [[ -n "$queue_name" ]]; then - where_clause="$where_clause AND queue_name = '$queue_name'" - fi - if [[ -n "$job_type" ]]; then - where_clause="$where_clause AND job_type = '$job_type'" - fi - - # Get failed jobs - local failed_jobs - failed_jobs=$(plugin_db_query " - SELECT id, job_type, queue_name, retry_count, max_retries - FROM jobs - WHERE $where_clause - ORDER BY failed_at DESC - LIMIT $limit; - ") - - if [[ -z "$failed_jobs" ]]; then - plugin_warn "No failed jobs found to retry" - return 0 - fi - - # Count jobs to retry - local count - count=$(echo "$failed_jobs" | grep -c "^" || echo "0") - - printf "\n" - printf "Found %s failed job(s) to retry:\n" "$count" - echo "$failed_jobs" | column -t -s '|' - printf "\n" - - read -p "Retry these jobs? (yes/no): " confirm - if [[ "$confirm" != "yes" ]]; then - plugin_warn "Retry cancelled" - return 0 - fi - - # Reset jobs to 'waiting' status - plugin_db_query " - UPDATE jobs - SET - status = 'waiting', - updated_at = NOW() - WHERE $where_clause - LIMIT $limit; - " - - # Use TypeScript CLI to re-queue jobs if available - if [[ -f "${PLUGIN_DIR}/ts/dist/cli.js" ]]; then - plugin_info "Re-queuing jobs in BullMQ..." - node "${PLUGIN_DIR}/ts/dist/cli.js" retry \ - $([ -n "$queue_name" ] && echo "--queue $queue_name") \ - $([ -n "$job_type" ] && echo "--type $job_type") \ - --limit "$limit" 2>/dev/null || plugin_warn "Failed to re-queue some jobs" - fi - - plugin_success "Retried $count job(s)" -} - -# Retry specific job by ID -retry_job() { - local job_id="$1" - - plugin_info "Retrying job: $job_id" - - # Get job details - local job - job=$(plugin_db_query " - SELECT id, job_type, queue_name, status, retry_count, max_retries - FROM jobs - WHERE id = '$job_id'; - ") - - if [[ -z "$job" ]]; then - plugin_error "Job not found: $job_id" - return 1 - fi - - # Check if job can be retried - local status - status=$(echo "$job" | cut -d'|' -f4 | tr -d ' ') - - if [[ "$status" != "failed" ]]; then - plugin_error "Job is not in 'failed' status (current: $status)" - return 1 - fi - - # Reset job status - plugin_db_query " - UPDATE jobs - SET - status = 'waiting', - updated_at = NOW() - WHERE id = '$job_id'; - " - - # Re-queue in BullMQ - if [[ -f "${PLUGIN_DIR}/ts/dist/cli.js" ]]; then - node "${PLUGIN_DIR}/ts/dist/cli.js" retry --id "$job_id" 2>/dev/null || \ - plugin_warn "Failed to re-queue job in BullMQ" - fi - - plugin_success "Job $job_id retried successfully" -} - -# Show retryable jobs -show_retryable() { - local queue_name="${1:-}" - - plugin_info "Retryable Jobs" - printf "\n" - - local where_clause="status = 'failed' AND retry_count < max_retries" - if [[ -n "$queue_name" ]]; then - where_clause="$where_clause AND queue_name = '$queue_name'" - fi - - plugin_db_query " - SELECT - j.id, - j.job_type, - j.queue_name, - j.retry_count || '/' || j.max_retries AS retries, - f.error_message, - TO_CHAR(j.failed_at, 'YYYY-MM-DD HH24:MI:SS') AS failed_at - FROM jobs j - LEFT JOIN LATERAL ( - SELECT error_message - FROM job_failures - WHERE job_id = j.id - ORDER BY failed_at DESC - LIMIT 1 - ) f ON TRUE - WHERE $where_clause - ORDER BY j.failed_at DESC - LIMIT 50; - " | column -t -s '|' - - printf "\n" -} - -# Show help -show_help() { - printf "Usage: nself plugin jobs retry [OPTIONS]\n\n" - printf "Retry failed jobs that haven't exceeded max retry attempts.\n\n" - printf "Options:\n" - printf " -i, --id JOB_ID Retry specific job by ID\n" - printf " -q, --queue QUEUE Filter by queue name\n" - printf " -t, --type TYPE Filter by job type\n" - printf " -l, --limit N Limit number of jobs to retry (default: 10)\n" - printf " -s, --show Show retryable jobs without retrying\n" - printf " -h, --help Show this help\n\n" - printf "Examples:\n" - printf " nself plugin jobs retry # Retry up to 10 failed jobs\n" - printf " nself plugin jobs retry -q default -l 20 # Retry 20 jobs from 'default' queue\n" - printf " nself plugin jobs retry -t send-email # Retry failed email jobs\n" - printf " nself plugin jobs retry -i # Retry specific job\n" - printf " nself plugin jobs retry -s # Show retryable jobs\n" -} - -# Parse arguments -JOB_ID="" -QUEUE="" -TYPE="" -LIMIT=10 -SHOW_ONLY=false - -while [[ $# -gt 0 ]]; do - case "$1" in - -i|--id) - JOB_ID="$2" - shift 2 - ;; - -q|--queue) - QUEUE="$2" - shift 2 - ;; - -t|--type) - TYPE="$2" - shift 2 - ;; - -l|--limit) - LIMIT="$2" - shift 2 - ;; - -s|--show) - SHOW_ONLY=true - shift - ;; - -h|--help|help) - show_help - exit 0 - ;; - *) - plugin_error "Unknown option: $1" - show_help - exit 1 - ;; - esac -done - -# Main execution -if [[ "$SHOW_ONLY" == "true" ]]; then - show_retryable "$QUEUE" -elif [[ -n "$JOB_ID" ]]; then - retry_job "$JOB_ID" -else - retry_failed "$QUEUE" "$TYPE" "$LIMIT" -fi diff --git a/free/jobs/actions/schedule.sh b/free/jobs/actions/schedule.sh deleted file mode 100755 index 4b9a23a20..000000000 --- a/free/jobs/actions/schedule.sh +++ /dev/null @@ -1,310 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Jobs Schedule Action -# Manage scheduled (cron) jobs -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Scheduled Jobs Management -# ============================================================================= - -# List scheduled jobs -list_schedules() { - plugin_info "Scheduled Jobs" - printf "\n" - - plugin_db_query " - SELECT - name, - job_type, - cron_expression, - CASE WHEN enabled THEN 'Yes' ELSE 'No' END AS enabled, - total_runs, - COALESCE(success_rate, 0) AS success_rate, - TO_CHAR(next_run_at, 'YYYY-MM-DD HH24:MI:SS') AS next_run, - TO_CHAR(last_run_at, 'YYYY-MM-DD HH24:MI:SS') AS last_run - FROM scheduled_jobs_overview - ORDER BY next_run_at; - " | column -t -s '|' - - printf "\n" -} - -# Show schedule details -show_schedule() { - local name="$1" - - plugin_info "Schedule Details: $name" - printf "\n" - - local details - details=$(plugin_db_query " - SELECT - id, - name, - description, - job_type, - queue_name, - cron_expression, - timezone, - enabled, - total_runs, - successful_runs, - failed_runs, - max_runs, - TO_CHAR(last_run_at, 'YYYY-MM-DD HH24:MI:SS') AS last_run, - TO_CHAR(next_run_at, 'YYYY-MM-DD HH24:MI:SS') AS next_run, - TO_CHAR(end_date, 'YYYY-MM-DD HH24:MI:SS') AS end_date, - TO_CHAR(created_at, 'YYYY-MM-DD HH24:MI:SS') AS created - FROM job_schedules - WHERE name = '$name'; - ") - - if [[ -z "$details" ]]; then - plugin_error "Schedule not found: $name" - return 1 - fi - - echo "$details" | tr '|' '\n' | awk '{print " " $0}' - - printf "\n" - - # Show payload - plugin_info "Payload:" - plugin_db_query "SELECT payload FROM job_schedules WHERE name = '$name';" | \ - python3 -m json.tool 2>/dev/null || cat - - printf "\n" - - # Show recent runs - plugin_info "Recent Runs (last 10):" - plugin_db_query " - SELECT - TO_CHAR(j.created_at, 'YYYY-MM-DD HH24:MI:SS') AS created, - j.status, - ROUND(EXTRACT(EPOCH FROM (j.completed_at - j.started_at)), 2) AS duration_sec, - CASE WHEN j.status = 'failed' THEN f.error_message ELSE '' END AS error - FROM jobs j - LEFT JOIN job_schedules s ON j.id = s.last_job_id OR j.metadata->>'schedule_name' = s.name - LEFT JOIN LATERAL ( - SELECT error_message - FROM job_failures - WHERE job_id = j.id - ORDER BY failed_at DESC - LIMIT 1 - ) f ON TRUE - WHERE s.name = '$name' - ORDER BY j.created_at DESC - LIMIT 10; - " | column -t -s '|' - - printf "\n" -} - -# Enable schedule -enable_schedule() { - local name="$1" - - plugin_db_query " - UPDATE job_schedules - SET enabled = TRUE, updated_at = NOW() - WHERE name = '$name'; - " - - plugin_success "Schedule '$name' enabled" -} - -# Disable schedule -disable_schedule() { - local name="$1" - - plugin_db_query " - UPDATE job_schedules - SET enabled = FALSE, updated_at = NOW() - WHERE name = '$name'; - " - - plugin_success "Schedule '$name' disabled" -} - -# Delete schedule -delete_schedule() { - local name="$1" - - read -p "Delete schedule '$name'? (yes/no): " confirm - if [[ "$confirm" != "yes" ]]; then - plugin_warn "Delete cancelled" - return 0 - fi - - plugin_db_query "DELETE FROM job_schedules WHERE name = '$name';" - - plugin_success "Schedule '$name' deleted" -} - -# Create schedule -create_schedule() { - local name="$1" - local job_type="$2" - local cron="$3" - local payload="${4:-{}}" - local queue="${5:-default}" - local description="${6:-}" - - # Validate cron expression (basic check) - if ! echo "$cron" | grep -qE '^[0-9*/,-]+ [0-9*/,-]+ [0-9*/,-]+ [0-9*/,-]+ [0-9*/,-]+'; then - plugin_error "Invalid cron expression: $cron" - return 1 - fi - - plugin_db_query " - INSERT INTO job_schedules (name, description, job_type, queue_name, cron_expression, payload) - VALUES ( - '$name', - $([ -n "$description" ] && echo "'$description'" || echo "NULL"), - '$job_type', - '$queue', - '$cron', - '$payload'::JSONB - ); - " - - plugin_success "Schedule '$name' created" -} - -# Show help -show_help() { - printf "Usage: nself plugin jobs schedule [COMMAND] [OPTIONS]\n\n" - printf "Manage scheduled (cron) jobs.\n\n" - printf "Commands:\n" - printf " list List all schedules (default)\n" - printf " show NAME Show schedule details\n" - printf " create Create a new schedule\n" - printf " enable NAME Enable a schedule\n" - printf " disable NAME Disable a schedule\n" - printf " delete NAME Delete a schedule\n\n" - printf "Create Options:\n" - printf " -n, --name NAME Schedule name (required)\n" - printf " -t, --type TYPE Job type (required)\n" - printf " -c, --cron EXPR Cron expression (required)\n" - printf " -p, --payload JSON Job payload (default: {})\n" - printf " -q, --queue QUEUE Queue name (default: default)\n" - printf " -d, --desc TEXT Description\n\n" - printf "Examples:\n" - printf " nself plugin jobs schedule list\n" - printf " nself plugin jobs schedule show cleanup-jobs\n" - printf " nself plugin jobs schedule create \\\\\n" - printf " -n daily-backup \\\\\n" - printf " -t database-backup \\\\\n" - printf " -c '0 2 * * *' \\\\\n" - printf " -p '{\"database\": \"production\"}' \\\\\n" - printf " -d 'Daily production database backup'\n" - printf " nself plugin jobs schedule enable daily-backup\n" - printf " nself plugin jobs schedule disable daily-backup\n" - printf " nself plugin jobs schedule delete daily-backup\n" -} - -# Parse command -COMMAND="${1:-list}" -shift || true - -case "$COMMAND" in - list) - list_schedules - ;; - show) - if [[ -z "${1:-}" ]]; then - plugin_error "Schedule name required" - show_help - exit 1 - fi - show_schedule "$1" - ;; - enable) - if [[ -z "${1:-}" ]]; then - plugin_error "Schedule name required" - exit 1 - fi - enable_schedule "$1" - ;; - disable) - if [[ -z "${1:-}" ]]; then - plugin_error "Schedule name required" - exit 1 - fi - disable_schedule "$1" - ;; - delete) - if [[ -z "${1:-}" ]]; then - plugin_error "Schedule name required" - exit 1 - fi - delete_schedule "$1" - ;; - create) - # Parse create options - NAME="" - TYPE="" - CRON="" - PAYLOAD="{}" - QUEUE="default" - DESC="" - - while [[ $# -gt 0 ]]; do - case "$1" in - -n|--name) - NAME="$2" - shift 2 - ;; - -t|--type) - TYPE="$2" - shift 2 - ;; - -c|--cron) - CRON="$2" - shift 2 - ;; - -p|--payload) - PAYLOAD="$2" - shift 2 - ;; - -q|--queue) - QUEUE="$2" - shift 2 - ;; - -d|--desc) - DESC="$2" - shift 2 - ;; - *) - plugin_error "Unknown option: $1" - show_help - exit 1 - ;; - esac - done - - if [[ -z "$NAME" ]] || [[ -z "$TYPE" ]] || [[ -z "$CRON" ]]; then - plugin_error "Name, type, and cron expression are required" - show_help - exit 1 - fi - - create_schedule "$NAME" "$TYPE" "$CRON" "$PAYLOAD" "$QUEUE" "$DESC" - ;; - -h|--help|help) - show_help - ;; - *) - plugin_error "Unknown command: $COMMAND" - show_help - exit 1 - ;; -esac diff --git a/free/jobs/actions/server.sh b/free/jobs/actions/server.sh deleted file mode 100755 index 667d2c5e1..000000000 --- a/free/jobs/actions/server.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Jobs Server Action -# Start BullBoard dashboard server -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Server -# ============================================================================= - -start_server() { - local port="${JOBS_DASHBOARD_PORT:-3105}" - local path="${JOBS_DASHBOARD_PATH:-/dashboard}" - - plugin_info "Starting BullBoard dashboard server..." - - # Check if TypeScript build exists - if [[ ! -f "${PLUGIN_DIR}/ts/dist/server.js" ]]; then - plugin_error "Server not built. Run 'cd ${PLUGIN_DIR}/ts && npm run build'" - return 1 - fi - - # Check Redis connection - if ! command -v redis-cli &> /dev/null; then - plugin_warn "redis-cli not found, skipping connection check" - else - local redis_url="${JOBS_REDIS_URL:-redis://localhost:6379}" - local redis_host=$(echo "$redis_url" | sed -E 's|redis://([^:]+).*|\1|') - local redis_port=$(echo "$redis_url" | sed -E 's|redis://[^:]+:([0-9]+).*|\1|') - - if ! redis-cli -h "${redis_host:-localhost}" -p "${redis_port:-6379}" ping &>/dev/null; then - plugin_error "Cannot connect to Redis at ${redis_url}" - return 1 - fi - fi - - printf "\n" - plugin_success "Dashboard will be available at: http://localhost:${port}${path}" - printf "\n" - - # Start server - cd "${PLUGIN_DIR}/ts" && node dist/server.js -} - -# Show help -show_help() { - printf "Usage: nself plugin jobs server [OPTIONS]\n\n" - printf "Start the BullBoard dashboard server.\n\n" - printf "Options:\n" - printf " -h, --help Show this help\n\n" - printf "Environment:\n" - printf " JOBS_REDIS_URL Redis connection URL (required)\n" - printf " JOBS_DASHBOARD_PORT Dashboard port (default: 3105)\n" - printf " JOBS_DASHBOARD_PATH Dashboard path (default: /dashboard)\n" - printf " JOBS_DASHBOARD_ENABLED Enable dashboard (default: true)\n\n" - printf "Example:\n" - printf " nself plugin jobs server\n" - printf " JOBS_DASHBOARD_PORT=4000 nself plugin jobs server\n" -} - -# Parse arguments -case "${1:-}" in - -h|--help|help) - show_help - ;; - *) - start_server - ;; -esac diff --git a/free/jobs/actions/stats.sh b/free/jobs/actions/stats.sh deleted file mode 100755 index 5070666df..000000000 --- a/free/jobs/actions/stats.sh +++ /dev/null @@ -1,269 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Jobs Stats Action -# View job statistics and metrics -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Statistics -# ============================================================================= - -show_stats() { - local queue_name="${1:-}" - local hours="${2:-24}" - - plugin_info "Job Statistics" - printf "\n" - - # Overall stats - printf "=== Overall Statistics (last %s hours) ===\n" "$hours" - plugin_db_query " - SELECT - metric, - value - FROM get_job_stats($([ -n "$queue_name" ] && echo "'$queue_name'" || echo "NULL"), $hours) - ORDER BY - CASE metric - WHEN 'total_jobs' THEN 1 - WHEN 'waiting' THEN 2 - WHEN 'active' THEN 3 - WHEN 'completed' THEN 4 - WHEN 'failed' THEN 5 - END; - " | column -t -s '|' - - printf "\n" - - # Queue stats - printf "=== Queue Statistics ===\n" - plugin_db_query " - SELECT - queue_name, - waiting, - active, - completed, - failed, - delayed, - total, - ROUND(avg_duration_seconds::numeric, 2) AS avg_duration_sec - FROM queue_stats - $([ -n "$queue_name" ] && echo "WHERE queue_name = '$queue_name'") - ORDER BY queue_name; - " | column -t -s '|' - - printf "\n" - - # Job type stats - printf "=== Job Type Statistics ===\n" - plugin_db_query " - SELECT - job_type, - total_jobs, - completed, - failed, - COALESCE(success_rate, 0) AS success_rate, - ROUND(COALESCE(avg_duration_seconds, 0)::numeric, 2) AS avg_duration_sec - FROM job_type_stats - ORDER BY total_jobs DESC - LIMIT 20; - " | column -t -s '|' - - printf "\n" - - # Recent failures - printf "=== Recent Failures (last 24 hours) ===\n" - plugin_db_query " - SELECT - job_type, - queue_name, - error_message, - attempt_number, - CASE WHEN will_retry THEN 'Yes' ELSE 'No' END AS will_retry, - TO_CHAR(failed_at, 'YYYY-MM-DD HH24:MI:SS') AS failed_at - FROM recent_failures - ORDER BY failed_at DESC - LIMIT 10; - " | column -t -s '|' - - printf "\n" - - # Active jobs - printf "=== Active Jobs ===\n" - plugin_db_query " - SELECT - job_type, - queue_name, - progress || '%' AS progress, - running_seconds || 's' AS running, - worker_id - FROM jobs_active - ORDER BY started_at - LIMIT 10; - " | column -t -s '|' - - printf "\n" - - # Scheduled jobs - printf "=== Scheduled Jobs ===\n" - plugin_db_query " - SELECT - name, - job_type, - cron_expression, - CASE WHEN enabled THEN 'Yes' ELSE 'No' END AS enabled, - total_runs, - COALESCE(success_rate, 0) AS success_rate, - CASE - WHEN seconds_until_next_run < 0 THEN 'Overdue' - WHEN seconds_until_next_run < 60 THEN seconds_until_next_run || 's' - WHEN seconds_until_next_run < 3600 THEN ROUND(seconds_until_next_run / 60.0) || 'm' - ELSE ROUND(seconds_until_next_run / 3600.0) || 'h' - END AS next_run_in - FROM scheduled_jobs_overview - ORDER BY next_run_at - LIMIT 10; - " | column -t -s '|' - - printf "\n" -} - -# Show performance metrics -show_performance() { - plugin_info "Performance Metrics (last 24 hours)" - printf "\n" - - # Job throughput - printf "=== Job Throughput ===\n" - plugin_db_query " - SELECT - DATE_TRUNC('hour', created_at) AS hour, - COUNT(*) AS total_jobs, - COUNT(*) FILTER (WHERE status = 'completed') AS completed, - COUNT(*) FILTER (WHERE status = 'failed') AS failed, - ROUND(AVG(EXTRACT(EPOCH FROM (completed_at - started_at))) FILTER (WHERE status = 'completed'), 2) AS avg_duration_sec - FROM jobs - WHERE created_at > NOW() - INTERVAL '24 hours' - GROUP BY DATE_TRUNC('hour', created_at) - ORDER BY hour DESC - LIMIT 24; - " | column -t -s '|' - - printf "\n" - - # Slowest jobs - printf "=== Slowest Jobs (last 24 hours) ===\n" - plugin_db_query " - SELECT - job_type, - queue_name, - ROUND(EXTRACT(EPOCH FROM (completed_at - started_at)), 2) AS duration_sec, - TO_CHAR(completed_at, 'YYYY-MM-DD HH24:MI:SS') AS completed_at - FROM jobs - WHERE status = 'completed' - AND completed_at > NOW() - INTERVAL '24 hours' - AND completed_at IS NOT NULL - AND started_at IS NOT NULL - ORDER BY (completed_at - started_at) DESC - LIMIT 10; - " | column -t -s '|' - - printf "\n" - - # Retry stats - printf "=== Retry Statistics ===\n" - plugin_db_query " - SELECT - job_type, - COUNT(*) AS total_with_retries, - AVG(retry_count) AS avg_retries, - MAX(retry_count) AS max_retries - FROM jobs - WHERE retry_count > 0 - AND created_at > NOW() - INTERVAL '24 hours' - GROUP BY job_type - ORDER BY total_with_retries DESC; - " | column -t -s '|' - - printf "\n" -} - -# Show help -show_help() { - printf "Usage: nself plugin jobs stats [OPTIONS]\n\n" - printf "View job statistics and performance metrics.\n\n" - printf "Options:\n" - printf " -q, --queue QUEUE Filter by queue name\n" - printf " -t, --time HOURS Time window in hours (default: 24)\n" - printf " -p, --performance Show detailed performance metrics\n" - printf " -w, --watch Watch mode (refresh every 5 seconds)\n" - printf " -h, --help Show this help\n\n" - printf "Examples:\n" - printf " nself plugin jobs stats # Overall stats\n" - printf " nself plugin jobs stats -q default # Stats for 'default' queue\n" - printf " nself plugin jobs stats -t 48 # Last 48 hours\n" - printf " nself plugin jobs stats -p # Performance metrics\n" - printf " nself plugin jobs stats -w # Watch mode\n" -} - -# Parse arguments -QUEUE="" -HOURS=24 -PERFORMANCE=false -WATCH=false - -while [[ $# -gt 0 ]]; do - case "$1" in - -q|--queue) - QUEUE="$2" - shift 2 - ;; - -t|--time) - HOURS="$2" - shift 2 - ;; - -p|--performance) - PERFORMANCE=true - shift - ;; - -w|--watch) - WATCH=true - shift - ;; - -h|--help|help) - show_help - exit 0 - ;; - *) - plugin_error "Unknown option: $1" - show_help - exit 1 - ;; - esac -done - -# Main execution -if [[ "$WATCH" == "true" ]]; then - while true; do - clear - if [[ "$PERFORMANCE" == "true" ]]; then - show_performance - else - show_stats "$QUEUE" "$HOURS" - fi - printf "Refreshing in 5 seconds... (Ctrl+C to stop)\n" - sleep 5 - done -else - if [[ "$PERFORMANCE" == "true" ]]; then - show_performance - else - show_stats "$QUEUE" "$HOURS" - fi -fi diff --git a/free/jobs/actions/worker.sh b/free/jobs/actions/worker.sh deleted file mode 100755 index 175d69a37..000000000 --- a/free/jobs/actions/worker.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Jobs Worker Action -# Start job worker process -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Worker -# ============================================================================= - -start_worker() { - local queue="${1:-default}" - local concurrency="${JOBS_DEFAULT_CONCURRENCY:-5}" - - plugin_info "Starting worker for queue: $queue (concurrency: $concurrency)..." - - # Check if TypeScript build exists - if [[ ! -f "${PLUGIN_DIR}/ts/dist/worker.js" ]]; then - plugin_error "Worker not built. Run 'cd ${PLUGIN_DIR}/ts && npm run build'" - return 1 - fi - - # Check Redis connection - if ! command -v redis-cli &> /dev/null; then - plugin_warn "redis-cli not found, skipping connection check" - else - local redis_url="${JOBS_REDIS_URL:-redis://localhost:6379}" - local redis_host=$(echo "$redis_url" | sed -E 's|redis://([^:]+).*|\1|') - local redis_port=$(echo "$redis_url" | sed -E 's|redis://[^:]+:([0-9]+).*|\1|') - - if ! redis-cli -h "${redis_host:-localhost}" -p "${redis_port:-6379}" ping &>/dev/null; then - plugin_error "Cannot connect to Redis at ${redis_url}" - return 1 - fi - fi - - printf "\n" - plugin_success "Worker started for queue '$queue'" - printf "\n" - - # Start worker - export WORKER_QUEUE="$queue" - export WORKER_CONCURRENCY="$concurrency" - cd "${PLUGIN_DIR}/ts" && node dist/worker.js -} - -# Show help -show_help() { - printf "Usage: nself plugin jobs worker [QUEUE] [OPTIONS]\n\n" - printf "Start a job worker process.\n\n" - printf "Arguments:\n" - printf " QUEUE Queue name (default: default)\n\n" - printf "Options:\n" - printf " -c, --concurrency N Worker concurrency (default: 5)\n" - printf " -h, --help Show this help\n\n" - printf "Environment:\n" - printf " JOBS_REDIS_URL Redis connection URL (required)\n" - printf " JOBS_DEFAULT_CONCURRENCY Default concurrency (default: 5)\n" - printf " JOBS_JOB_TIMEOUT Job timeout in ms (default: 60000)\n" - printf " JOBS_RETRY_ATTEMPTS Max retry attempts (default: 3)\n\n" - printf "Examples:\n" - printf " nself plugin jobs worker # Start worker for 'default' queue\n" - printf " nself plugin jobs worker high-priority # Start worker for 'high-priority' queue\n" - printf " JOBS_DEFAULT_CONCURRENCY=10 nself plugin jobs worker\n" -} - -# Parse arguments -QUEUE="default" - -while [[ $# -gt 0 ]]; do - case "$1" in - -c|--concurrency) - export JOBS_DEFAULT_CONCURRENCY="$2" - shift 2 - ;; - -h|--help|help) - show_help - exit 0 - ;; - *) - QUEUE="$1" - shift - ;; - esac -done - -start_worker "$QUEUE" diff --git a/free/jobs/install.sh b/free/jobs/install.sh deleted file mode 100755 index c2d610c46..000000000 --- a/free/jobs/install.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Jobs Plugin Installer -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" -source "${SHARED_DIR}/schema-sync.sh" - -# ============================================================================= -# Installation -# ============================================================================= - -install_jobs_plugin() { - plugin_info "Installing Jobs plugin..." - - # Check required environment variables - if ! plugin_check_env "jobs" "JOBS_REDIS_URL"; then - plugin_error "JOBS_REDIS_URL is required for the Jobs plugin" - plugin_info "Add to your .env: JOBS_REDIS_URL=redis://localhost:6379" - return 1 - fi - - # Validate Redis connection - plugin_info "Checking Redis connection..." - if command -v redis-cli &> /dev/null; then - local redis_url="${JOBS_REDIS_URL}" - local redis_host=$(echo "$redis_url" | sed -E 's|redis://([^:]+).*|\1|') - local redis_port=$(echo "$redis_url" | sed -E 's|redis://[^:]+:([0-9]+).*|\1|') - - if redis-cli -h "${redis_host:-localhost}" -p "${redis_port:-6379}" ping &>/dev/null; then - plugin_success "Redis connection successful" - else - plugin_warn "Cannot connect to Redis at ${redis_url}" - plugin_info "Make sure Redis is running before starting workers" - fi - else - plugin_warn "redis-cli not found, skipping connection check" - fi - - # Apply database schema - plugin_info "Applying database schema..." - - # Ensure migrations table exists - schema_ensure_migrations_table - - # Apply main schema - if [[ -f "${PLUGIN_DIR}/schema/tables.sql" ]]; then - plugin_db_exec_file "${PLUGIN_DIR}/schema/tables.sql" - fi - - # Apply migrations - if [[ -d "${PLUGIN_DIR}/schema/migrations" ]]; then - for migration in "${PLUGIN_DIR}/schema/migrations"/*.sql; do - [[ ! -f "$migration" ]] && continue - - local migration_name - migration_name=$(basename "$migration" .sql) - - if ! schema_migration_applied "jobs" "$migration_name"; then - plugin_info "Applying migration: $migration_name" - plugin_db_exec_file "$migration" - schema_record_migration "jobs" "$migration_name" - fi - done - fi - - # Create cache and log directories - mkdir -p "${HOME}/.nself/cache/plugins/jobs" - mkdir -p "${HOME}/.nself/logs/plugins/jobs" - - # Install TypeScript dependencies if package.json exists - if [[ -f "${PLUGIN_DIR}/ts/package.json" ]]; then - plugin_info "Installing dependencies..." - (cd "${PLUGIN_DIR}/ts" && npm install --silent) - - plugin_info "Building TypeScript..." - (cd "${PLUGIN_DIR}/ts" && npm run build --silent) - fi - - plugin_success "Jobs plugin installed successfully!" - - printf "\n" - printf "Next steps:\n" - printf " 1. Ensure Redis is running at: %s\n" "${JOBS_REDIS_URL}" - printf " 2. Start the BullBoard dashboard: nself plugin jobs server\n" - printf " 3. Start a worker process: nself plugin jobs worker\n" - printf " 4. View job stats: nself plugin jobs stats\n" - printf "\n" - printf "Configuration:\n" - printf " Dashboard: http://localhost:%s%s\n" \ - "${JOBS_DASHBOARD_PORT:-3105}" \ - "${JOBS_DASHBOARD_PATH:-/dashboard}" - printf " Default concurrency: %s\n" "${JOBS_DEFAULT_CONCURRENCY:-5}" - printf " Max retries: %s\n" "${JOBS_RETRY_ATTEMPTS:-3}" - printf "\n" -} - -# Run installation -install_jobs_plugin diff --git a/free/jobs/uninstall.sh b/free/jobs/uninstall.sh deleted file mode 100755 index b35084d18..000000000 --- a/free/jobs/uninstall.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Jobs Plugin Uninstaller -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Uninstallation -# ============================================================================= - -uninstall_jobs_plugin() { - plugin_info "Uninstalling Jobs plugin..." - - # Confirm uninstallation - printf "\n" - printf "This will:\n" - printf " - Drop all job tables (jobs, job_results, job_failures, job_schedules)\n" - printf " - Remove all job data from the database\n" - printf " - Remove cache and log files\n" - printf " - Clear Redis queues (if JOBS_CLEAR_REDIS=true)\n" - printf "\n" - printf "Continue? (yes/no): " - read -r confirm - printf "\n" - - if [[ "$confirm" != "yes" ]]; then - plugin_warn "Uninstallation cancelled" - return 0 - fi - - # Drop tables and types - plugin_info "Dropping database tables..." - - plugin_db_query " - DROP VIEW IF EXISTS scheduled_jobs_overview CASCADE; - DROP VIEW IF EXISTS recent_failures CASCADE; - DROP VIEW IF EXISTS job_type_stats CASCADE; - DROP VIEW IF EXISTS queue_stats CASCADE; - DROP VIEW IF EXISTS jobs_failed_details CASCADE; - DROP VIEW IF EXISTS jobs_active CASCADE; - - DROP FUNCTION IF EXISTS get_job_stats(VARCHAR, INTEGER) CASCADE; - DROP FUNCTION IF EXISTS cleanup_old_failed_jobs(INTEGER) CASCADE; - DROP FUNCTION IF EXISTS cleanup_old_jobs(INTEGER) CASCADE; - DROP FUNCTION IF EXISTS update_job_status() CASCADE; - - DROP TABLE IF EXISTS job_schedules CASCADE; - DROP TABLE IF EXISTS job_failures CASCADE; - DROP TABLE IF EXISTS job_results CASCADE; - DROP TABLE IF EXISTS jobs CASCADE; - - DROP TYPE IF EXISTS job_priority CASCADE; - DROP TYPE IF EXISTS job_status CASCADE; - " || plugin_warn "Failed to drop some database objects" - - # Clear Redis queues if requested - if [[ "${JOBS_CLEAR_REDIS:-false}" == "true" ]] && command -v redis-cli &> /dev/null; then - plugin_info "Clearing Redis queues..." - - local redis_url="${JOBS_REDIS_URL:-redis://localhost:6379}" - local redis_host=$(echo "$redis_url" | sed -E 's|redis://([^:]+).*|\1|') - local redis_port=$(echo "$redis_url" | sed -E 's|redis://[^:]+:([0-9]+).*|\1|') - - # Clear BullMQ queues - redis-cli -h "${redis_host:-localhost}" -p "${redis_port:-6379}" \ - --scan --pattern "bull:*" | xargs -r redis-cli -h "${redis_host:-localhost}" -p "${redis_port:-6379}" DEL \ - 2>/dev/null || plugin_warn "Failed to clear Redis queues" - fi - - # Remove cache and log directories - plugin_info "Removing cache and log files..." - rm -rf "${HOME}/.nself/cache/plugins/jobs" - rm -rf "${HOME}/.nself/logs/plugins/jobs" - - # Remove migration records - plugin_db_query " - DELETE FROM plugin_migrations WHERE plugin_name = 'jobs'; - " 2>/dev/null || true - - plugin_success "Jobs plugin uninstalled successfully!" - - printf "\n" - printf "Note: Redis data was %s\n" "${JOBS_CLEAR_REDIS:-false}" - printf "To clear Redis manually, run:\n" - printf " redis-cli --scan --pattern 'bull:*' | xargs redis-cli DEL\n" - printf "\n" -} - -# Run uninstallation -uninstall_jobs_plugin diff --git a/free/notifications/IMPLEMENTATION_SUMMARY.md b/free/notifications/IMPLEMENTATION_SUMMARY.md index 878732f3e..4ea4681d6 100644 --- a/free/notifications/IMPLEMENTATION_SUMMARY.md +++ b/free/notifications/IMPLEMENTATION_SUMMARY.md @@ -17,8 +17,6 @@ Complete, production-ready multi-channel notification system for nself with emai |------|-------|-------------| | `plugin.json` | 98 | Plugin manifest with metadata, tables, webhooks, actions | | `README.md` | 616 | Comprehensive documentation | -| `install.sh` | 78 | Installation script | -| `uninstall.sh` | 83 | Uninstallation script | | `.env.example` | 153 | Complete environment configuration | ### 2. Database Schema (444 lines) @@ -47,18 +45,7 @@ Complete, production-ready multi-channel notification system for nself with emai - 4 default templates (welcome, password reset, email verification, password changed) - 11 provider configurations (Resend, SendGrid, Mailgun, SES, SMTP, FCM, OneSignal, Web Push, Twilio, Plivo, SNS) -### 3. Action Scripts (6) - -| Script | Lines | Purpose | -|--------|-------|---------| -| `init.sh` | 145 | Initialize and verify setup | -| `test.sh` | 178 | Send test notifications | -| `template.sh` | 288 | Manage templates (CRUD) | -| `stats.sh` | 261 | View statistics and analytics | -| `server.sh` | 84 | Start HTTP/GraphQL server | -| `worker.sh` | 88 | Start background queue worker | - -### 4. TypeScript Implementation (1,800+ lines) +### 3. TypeScript Implementation (1,800+ lines) **Core Modules**: - `types.ts` (462 lines) - Complete type definitions @@ -160,29 +147,26 @@ nself plugin notifications worker [--concurrency 10] [--poll-interval 500] ## Installation ```bash -# 1. Navigate to plugin directory -cd ~/Sites/nself-plugins/plugins/notifications +# 1. Install the plugin +nself plugin install notifications -# 2. Run installer -bash install.sh - -# 3. Install TypeScript dependencies +# 2. Install TypeScript dependencies cd ts npm install npm run build -# 4. Configure environment +# 3. Configure environment cp .env.example .env # Edit .env with your provider credentials -# 5. Initialize +# 4. Initialize nself plugin notifications init -# 6. Start services +# 5. Start services nself plugin notifications server & nself plugin notifications worker & -# 7. Test +# 6. Test nself plugin notifications test email test@example.com ``` @@ -373,21 +357,11 @@ notifications/ ├── plugin.json # Plugin manifest ├── README.md # Documentation (616 lines) ├── IMPLEMENTATION_SUMMARY.md # This file -├── install.sh # Installer -├── uninstall.sh # Uninstaller ├── .env.example # Configuration template │ ├── schema/ │ └── tables.sql # Database schema (444 lines) │ -├── actions/ -│ ├── init.sh # Initialization -│ ├── test.sh # Testing -│ ├── template.sh # Template management -│ ├── stats.sh # Statistics -│ ├── server.sh # HTTP server -│ └── worker.sh # Queue worker -│ └── ts/ ├── package.json # Dependencies ├── tsconfig.json # TypeScript config diff --git a/free/notifications/actions/init.sh b/free/notifications/actions/init.sh deleted file mode 100755 index d4e25a67c..000000000 --- a/free/notifications/actions/init.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Notifications Init Action -# Initialize notification system and verify setup -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Initialization -# ============================================================================= - -init_notifications() { - plugin_info "Initializing Notifications system..." - printf "\n" - - # Check database connection - plugin_info "Checking database connection..." - if ! plugin_db_query "SELECT 1;" >/dev/null 2>&1; then - plugin_error "Database connection failed" - return 1 - fi - plugin_success "Database connected" - - # Check tables exist - plugin_info "Verifying database schema..." - local tables=( - "notification_templates" - "notification_preferences" - "notifications" - "notification_queue" - "notification_providers" - "notification_batches" - ) - - local missing=0 - for table in "${tables[@]}"; do - if ! plugin_db_query "SELECT 1 FROM $table LIMIT 1;" >/dev/null 2>&1; then - plugin_error "Table missing: $table" - missing=1 - fi - done - - if [[ $missing -eq 1 ]]; then - plugin_error "Schema verification failed. Run install.sh first." - return 1 - fi - plugin_success "All tables present" - - # Check for configured providers - plugin_info "Checking configured providers..." - - local enabled_count - enabled_count=$(plugin_db_query "SELECT COUNT(*) FROM notification_providers WHERE enabled = true;" 2>/dev/null | grep -o '[0-9]*' | head -1) - - if [[ -z "$enabled_count" || "$enabled_count" -eq 0 ]]; then - plugin_warn "No providers enabled yet" - printf "\n" - printf "Configure at least one provider:\n" - printf " Email: NOTIFICATIONS_EMAIL_PROVIDER=resend\n" - printf " NOTIFICATIONS_EMAIL_API_KEY=re_xxx\n" - printf " NOTIFICATIONS_EMAIL_FROM=noreply@example.com\n" - printf "\n" - printf " Push: NOTIFICATIONS_PUSH_PROVIDER=fcm\n" - printf " NOTIFICATIONS_PUSH_API_KEY=xxx\n" - printf "\n" - printf " SMS: NOTIFICATIONS_SMS_PROVIDER=twilio\n" - printf " NOTIFICATIONS_SMS_ACCOUNT_SID=xxx\n" - printf " NOTIFICATIONS_SMS_AUTH_TOKEN=xxx\n" - printf " NOTIFICATIONS_SMS_FROM=+1234567890\n" - printf "\n" - else - plugin_success "$enabled_count provider(s) enabled" - fi - - # Check templates - local template_count - template_count=$(plugin_db_query "SELECT COUNT(*) FROM notification_templates;" 2>/dev/null | grep -o '[0-9]*' | head -1) - plugin_info "Templates available: $template_count" - - # Show statistics - printf "\n" - plugin_info "System Statistics:" - printf "\n" - - local total_sent - total_sent=$(plugin_db_query "SELECT COUNT(*) FROM notifications;" 2>/dev/null | grep -o '[0-9]*' | head -1) - printf " Total notifications sent: %s\n" "$total_sent" - - local queued - queued=$(plugin_db_query "SELECT COUNT(*) FROM notification_queue WHERE status = 'pending';" 2>/dev/null | grep -o '[0-9]*' | head -1) - printf " Queued notifications: %s\n" "$queued" - - local failed - failed=$(plugin_db_query "SELECT COUNT(*) FROM notifications WHERE status = 'failed';" 2>/dev/null | grep -o '[0-9]*' | head -1) - printf " Failed notifications: %s\n" "$failed" - - printf "\n" - plugin_success "Initialization complete!" - - printf "\n" - printf "Next commands:\n" - printf " nself plugin notifications template list # List templates\n" - printf " nself plugin notifications test # Send test notification\n" - printf " nself plugin notifications server # Start API server\n" - printf " nself plugin notifications worker # Start queue worker\n" - printf "\n" -} - -# Show help -show_help() { - printf "Usage: nself plugin notifications init\n\n" - printf "Initialize and verify notification system setup.\n\n" - printf "Checks:\n" - printf " - Database connection\n" - printf " - Schema installation\n" - printf " - Provider configuration\n" - printf " - System statistics\n" -} - -# Parse arguments -case "${1:-}" in - -h|--help|help) - show_help - ;; - *) - init_notifications - ;; -esac diff --git a/free/notifications/actions/server.sh b/free/notifications/actions/server.sh deleted file mode 100755 index fc90ead7b..000000000 --- a/free/notifications/actions/server.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Notifications Server Action -# Start HTTP/GraphQL server for notifications -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Server -# ============================================================================= - -start_server() { - local port="${PORT:-3102}" - local host="${HOST:-0.0.0.0}" - - plugin_info "Starting Notifications server..." - plugin_info "Port: $port" - plugin_info "Host: $host" - printf "\n" - - # Check if TypeScript implementation exists - if [[ -f "${PLUGIN_DIR}/ts/dist/server.js" ]]; then - cd "${PLUGIN_DIR}/ts" - node dist/server.js - elif [[ -f "${PLUGIN_DIR}/ts/src/server.ts" ]]; then - cd "${PLUGIN_DIR}/ts" - if command -v tsx >/dev/null 2>&1; then - tsx src/server.ts - elif command -v ts-node >/dev/null 2>&1; then - ts-node src/server.ts - else - plugin_error "TypeScript runtime not found (tsx or ts-node required)" - printf "\nInstall with: npm install -g tsx\n" - return 1 - fi - else - plugin_error "Server implementation not found" - printf "\n" - printf "Build the TypeScript implementation:\n" - printf " cd %s/ts\n" "$PLUGIN_DIR" - printf " npm install\n" - printf " npm run build\n" - printf "\n" - return 1 - fi -} - -# Show help -show_help() { - printf "Usage: nself plugin notifications server [options]\n\n" - printf "Start the notification HTTP server.\n\n" - printf "Options:\n" - printf " --port PORT Server port (default: 3102)\n" - printf " --host HOST Server host (default: 0.0.0.0)\n\n" - printf "Environment:\n" - printf " PORT Server port\n" - printf " HOST Server host\n" - printf " DATABASE_URL PostgreSQL connection string\n\n" - printf "Endpoints:\n" - printf " GET /health Health check\n" - printf " POST /api/notifications/send Send notification\n" - printf " GET /api/notifications/:id Get notification status\n" - printf " GET /api/templates List templates\n" - printf " POST /api/preferences Update user preferences\n" -} - -# Parse arguments -while [[ $# -gt 0 ]]; do - case "$1" in - -h|--help|help) - show_help - exit 0 - ;; - --port) - export PORT="$2" - shift 2 - ;; - --host) - export HOST="$2" - shift 2 - ;; - *) - plugin_error "Unknown option: $1" - show_help - exit 1 - ;; - esac -done - -start_server diff --git a/free/notifications/actions/stats.sh b/free/notifications/actions/stats.sh deleted file mode 100755 index 5cbb7c547..000000000 --- a/free/notifications/actions/stats.sh +++ /dev/null @@ -1,308 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Notifications Statistics Action -# View notification metrics and analytics -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Statistics -# ============================================================================= - -show_overview() { - plugin_info "Notification Statistics Overview" - printf "\n" - - # Overall stats - plugin_info "Overall (last 30 days)" - printf "\n" - - plugin_db_query " - SELECT - channel, - COUNT(*) AS total, - COUNT(*) FILTER (WHERE status = 'delivered') AS delivered, - COUNT(*) FILTER (WHERE status = 'failed') AS failed, - COUNT(*) FILTER (WHERE status = 'pending') AS pending, - ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'delivered') / NULLIF(COUNT(*), 0), 2) AS delivery_rate - FROM notifications - WHERE created_at >= NOW() - INTERVAL '30 days' - GROUP BY channel - ORDER BY channel; - " 2>/dev/null | column -t -s '|' - - printf "\n" - - # By category - plugin_info "By Category" - printf "\n" - - plugin_db_query " - SELECT - category, - COUNT(*) AS total, - COUNT(*) FILTER (WHERE status = 'delivered') AS delivered, - ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'delivered') / NULLIF(COUNT(*), 0), 2) AS delivery_rate - FROM notifications - WHERE created_at >= NOW() - INTERVAL '30 days' - GROUP BY category - ORDER BY total DESC; - " 2>/dev/null | column -t -s '|' - - printf "\n" - - # Queue status - plugin_info "Queue Status" - printf "\n" - - plugin_db_query " - SELECT - status, - COUNT(*) AS count, - AVG(attempts) AS avg_attempts - FROM notification_queue - GROUP BY status - ORDER BY status; - " 2>/dev/null | column -t -s '|' - - printf "\n" -} - -show_delivery_rates() { - local days="${1:-7}" - - plugin_info "Delivery Rates (last $days days)" - printf "\n" - - plugin_db_query " - SELECT * FROM notification_delivery_rates - WHERE date >= NOW() - INTERVAL '$days days' - ORDER BY date DESC, channel - LIMIT 50; - " 2>/dev/null | column -t -s '|' -} - -show_engagement() { - local days="${1:-7}" - - plugin_info "Email Engagement (last $days days)" - printf "\n" - - plugin_db_query " - SELECT * FROM notification_engagement - WHERE date >= NOW() - INTERVAL '$days days' - ORDER BY date DESC - LIMIT 50; - " 2>/dev/null | column -t -s '|' -} - -show_provider_health() { - plugin_info "Provider Health Status" - printf "\n" - - plugin_db_query " - SELECT * FROM notification_provider_health - ORDER BY type, success_rate DESC; - " 2>/dev/null | column -t -s '|' -} - -show_top_templates() { - local limit="${1:-10}" - - plugin_info "Top Templates (last 30 days)" - printf "\n" - - plugin_db_query " - SELECT - template_name, - COUNT(*) AS total_sent, - COUNT(*) FILTER (WHERE status = 'delivered') AS delivered, - COUNT(*) FILTER (WHERE status = 'failed') AS failed, - ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'delivered') / NULLIF(COUNT(*), 0), 2) AS delivery_rate - FROM notifications - WHERE created_at >= NOW() - INTERVAL '30 days' - AND template_name IS NOT NULL - GROUP BY template_name - ORDER BY total_sent DESC - LIMIT $limit; - " 2>/dev/null | column -t -s '|' -} - -show_failures() { - local limit="${1:-20}" - - plugin_info "Recent Failures" - printf "\n" - - plugin_db_query " - SELECT - to_char(created_at, 'YYYY-MM-DD HH24:MI') AS time, - channel, - template_name, - error_code, - error_message - FROM notifications - WHERE status = 'failed' - ORDER BY created_at DESC - LIMIT $limit; - " 2>/dev/null | column -t -s '|' -} - -show_hourly_volume() { - local hours="${1:-24}" - - plugin_info "Hourly Volume (last $hours hours)" - printf "\n" - - plugin_db_query " - SELECT - to_char(date_trunc('hour', created_at), 'YYYY-MM-DD HH24:00') AS hour, - channel, - COUNT(*) AS count - FROM notifications - WHERE created_at >= NOW() - INTERVAL '$hours hours' - GROUP BY date_trunc('hour', created_at), channel - ORDER BY date_trunc('hour', created_at) DESC, channel; - " 2>/dev/null | column -t -s '|' -} - -export_stats() { - local format="${1:-json}" - local output_file="${2:-stats-$(date +%Y%m%d-%H%M%S).$format}" - - plugin_info "Exporting statistics to: $output_file" - - case "$format" in - json) - plugin_db_query " - SELECT json_build_object( - 'generated_at', NOW(), - 'overview', ( - SELECT json_agg(row_to_json(t)) - FROM ( - SELECT - channel, - COUNT(*) AS total, - COUNT(*) FILTER (WHERE status = 'delivered') AS delivered, - COUNT(*) FILTER (WHERE status = 'failed') AS failed - FROM notifications - WHERE created_at >= NOW() - INTERVAL '30 days' - GROUP BY channel - ) t - ), - 'providers', (SELECT json_agg(row_to_json(notification_provider_health)) FROM notification_provider_health), - 'delivery_rates', (SELECT json_agg(row_to_json(notification_delivery_rates)) FROM notification_delivery_rates WHERE date >= NOW() - INTERVAL '7 days') - ); - " > "$output_file" 2>/dev/null - ;; - csv) - plugin_db_query " - COPY ( - SELECT - created_at, - channel, - category, - template_name, - status, - provider, - retry_count - FROM notifications - WHERE created_at >= NOW() - INTERVAL '30 days' - ORDER BY created_at DESC - ) TO STDOUT WITH CSV HEADER; - " > "$output_file" 2>/dev/null - ;; - *) - plugin_error "Unsupported format: $format (use json or csv)" - return 1 - ;; - esac - - if [[ $? -eq 0 ]]; then - plugin_success "Exported to: $output_file" - else - plugin_error "Export failed" - return 1 - fi -} - -# ============================================================================= -# Main -# ============================================================================= - -show_stats() { - local stat_type="${1:-overview}" - shift || true - - case "$stat_type" in - overview|summary) - show_overview - ;; - delivery|deliveries) - show_delivery_rates "$@" - ;; - engagement|email) - show_engagement "$@" - ;; - providers|provider) - show_provider_health - ;; - templates|template) - show_top_templates "$@" - ;; - failures|errors|failed) - show_failures "$@" - ;; - hourly|volume) - show_hourly_volume "$@" - ;; - export) - export_stats "$@" - ;; - *) - show_help - return 1 - ;; - esac -} - -# Show help -show_help() { - printf "Usage: nself plugin notifications stats [args]\n\n" - printf "View notification statistics and analytics.\n\n" - printf "Statistics types:\n" - printf " overview Overall statistics (default)\n" - printf " delivery [days] Delivery rates (default: 7 days)\n" - printf " engagement [days] Email engagement metrics (default: 7 days)\n" - printf " providers Provider health status\n" - printf " templates [limit] Top templates (default: 10)\n" - printf " failures [limit] Recent failures (default: 20)\n" - printf " hourly [hours] Hourly volume (default: 24 hours)\n" - printf " export [format] [file] Export stats (json or csv)\n\n" - printf "Examples:\n" - printf " nself plugin notifications stats overview\n" - printf " nself plugin notifications stats delivery 30\n" - printf " nself plugin notifications stats failures 50\n" - printf " nself plugin notifications stats export json stats.json\n" -} - -# Parse arguments -if [[ $# -eq 0 ]]; then - show_overview - exit 0 -fi - -case "${1:-}" in - -h|--help|help) - show_help - ;; - *) - show_stats "$@" - ;; -esac diff --git a/free/notifications/actions/template.sh b/free/notifications/actions/template.sh deleted file mode 100755 index 5df429dfc..000000000 --- a/free/notifications/actions/template.sh +++ /dev/null @@ -1,298 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Notifications Template Action -# Manage notification templates -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Template Management -# ============================================================================= - -list_templates() { - plugin_info "Notification Templates" - printf "\n" - - local format="${1:-table}" - - if [[ "$format" == "json" ]]; then - plugin_db_query " - SELECT json_agg( - json_build_object( - 'id', id::text, - 'name', name, - 'category', category, - 'channels', channels, - 'active', active, - 'created_at', created_at - ) - ) - FROM notification_templates - ORDER BY category, name; - " 2>/dev/null - else - plugin_db_query " - SELECT - name, - category, - array_to_string(array(SELECT jsonb_array_elements_text(channels)), ', ') AS channels, - active, - to_char(created_at, 'YYYY-MM-DD') AS created - FROM notification_templates - ORDER BY category, name; - " 2>/dev/null | column -t -s '|' - fi -} - -show_template() { - local template_name="$1" - - plugin_info "Template: $template_name" - printf "\n" - - # Get template details - local template - template=$(plugin_db_query " - SELECT - id::text, - name, - category, - channels::text, - subject, - body_text, - body_html, - push_title, - push_body, - sms_body, - variables::text, - active, - created_at, - updated_at - FROM notification_templates - WHERE name = '$template_name'; - " 2>/dev/null) - - if [[ -z "$template" ]]; then - plugin_error "Template not found: $template_name" - return 1 - fi - - printf "%s\n" "$template" - printf "\n" - - # Show usage stats - plugin_info "Usage Statistics (last 30 days)" - printf "\n" - - plugin_db_query " - SELECT - COUNT(*) AS total_sent, - COUNT(*) FILTER (WHERE status = 'delivered') AS delivered, - COUNT(*) FILTER (WHERE status = 'failed') AS failed, - ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'delivered') / NULLIF(COUNT(*), 0), 2) AS delivery_rate - FROM notifications - WHERE template_name = '$template_name' - AND created_at >= NOW() - INTERVAL '30 days'; - " 2>/dev/null -} - -create_template() { - plugin_info "Creating new template..." - printf "\n" - - # Interactive creation - read -p "Template name (e.g., welcome_email): " name - read -p "Category (transactional/marketing/system/alert): " category - read -p "Channels (comma-separated, e.g., email,push): " channels - read -p "Subject (for email): " subject - read -p "Body text: " body_text - - # Escape single quotes for SQL - name=$(printf '%s' "$name" | sed "s/'/''/g") - category=$(printf '%s' "$category" | sed "s/'/''/g") - subject=$(printf '%s' "$subject" | sed "s/'/''/g") - body_text=$(printf '%s' "$body_text" | sed "s/'/''/g") - - # Convert channels to JSON array - local channels_json - channels_json="[$(echo "$channels" | sed "s/,/\",\"/g" | sed 's/^/\"/' | sed 's/$/\"/')]" - - local result - result=$(plugin_db_query " - INSERT INTO notification_templates (name, category, channels, subject, body_text) - VALUES ('$name', '$category', '$channels_json'::jsonb, '$subject', '$body_text') - RETURNING id::text; - " 2>&1) - - if [[ $? -eq 0 ]]; then - plugin_success "Template created: $name" - printf "ID: %s\n" "$result" - else - plugin_error "Failed to create template" - printf "%s\n" "$result" - return 1 - fi -} - -update_template() { - local template_name="$1" - - plugin_info "Updating template: $template_name" - printf "\n" - - # Check exists - local exists - exists=$(plugin_db_query "SELECT COUNT(*) FROM notification_templates WHERE name = '$template_name';" 2>/dev/null | grep -o '[0-9]*' | head -1) - - if [[ "$exists" -eq 0 ]]; then - plugin_error "Template not found: $template_name" - return 1 - fi - - printf "Leave fields blank to keep current value\n\n" - - read -p "Subject: " subject - read -p "Body text: " body_text - read -p "Active (true/false): " active - - local updates=() - - if [[ -n "$subject" ]]; then - subject=$(printf '%s' "$subject" | sed "s/'/''/g") - updates+=("subject = '$subject'") - fi - - if [[ -n "$body_text" ]]; then - body_text=$(printf '%s' "$body_text" | sed "s/'/''/g") - updates+=("body_text = '$body_text'") - fi - - if [[ -n "$active" ]]; then - updates+=("active = $active") - fi - - if [[ ${#updates[@]} -eq 0 ]]; then - plugin_warn "No updates provided" - return 0 - fi - - local update_clause - update_clause=$(IFS=,; echo "${updates[*]}") - - plugin_db_query " - UPDATE notification_templates - SET $update_clause - WHERE name = '$template_name'; - " >/dev/null 2>&1 - - if [[ $? -eq 0 ]]; then - plugin_success "Template updated" - else - plugin_error "Update failed" - return 1 - fi -} - -delete_template() { - local template_name="$1" - - plugin_warn "Delete template: $template_name" - printf "\n" - - read -p "Are you sure? [y/N] " -n 1 -r - printf "\n" - - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - plugin_info "Cancelled" - return 0 - fi - - plugin_db_query "DELETE FROM notification_templates WHERE name = '$template_name';" >/dev/null 2>&1 - - if [[ $? -eq 0 ]]; then - plugin_success "Template deleted" - else - plugin_error "Delete failed" - return 1 - fi -} - -# ============================================================================= -# Main -# ============================================================================= - -manage_templates() { - local action="${1:-list}" - shift || true - - case "$action" in - list|ls) - list_templates "$@" - ;; - show|get|view) - if [[ $# -eq 0 ]]; then - plugin_error "Template name required" - return 1 - fi - show_template "$1" - ;; - create|new|add) - create_template - ;; - update|edit) - if [[ $# -eq 0 ]]; then - plugin_error "Template name required" - return 1 - fi - update_template "$1" - ;; - delete|remove|rm) - if [[ $# -eq 0 ]]; then - plugin_error "Template name required" - return 1 - fi - delete_template "$1" - ;; - *) - show_help - return 1 - ;; - esac -} - -# Show help -show_help() { - printf "Usage: nself plugin notifications template [args]\n\n" - printf "Manage notification templates.\n\n" - printf "Actions:\n" - printf " list [json] List all templates (default: table format)\n" - printf " show Show template details and stats\n" - printf " create Create new template (interactive)\n" - printf " update Update template (interactive)\n" - printf " delete Delete template\n\n" - printf "Examples:\n" - printf " nself plugin notifications template list\n" - printf " nself plugin notifications template show welcome_email\n" - printf " nself plugin notifications template create\n" -} - -# Parse arguments -if [[ $# -eq 0 ]]; then - list_templates - exit 0 -fi - -case "${1:-}" in - -h|--help|help) - show_help - ;; - *) - manage_templates "$@" - ;; -esac diff --git a/free/notifications/actions/test.sh b/free/notifications/actions/test.sh deleted file mode 100755 index d2eb61381..000000000 --- a/free/notifications/actions/test.sh +++ /dev/null @@ -1,210 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Notifications Test Action -# Send test notifications to verify setup -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Test Functions -# ============================================================================= - -test_email() { - local recipient="${1:-}" - - if [[ -z "$recipient" ]]; then - plugin_error "Email recipient required" - printf "Usage: nself plugin notifications test email user@example.com\n" - return 1 - fi - - plugin_info "Sending test email to: $recipient" - - # Create test notification - local result - result=$(plugin_db_query " - INSERT INTO notifications ( - user_id, - channel, - category, - recipient_email, - subject, - body_text, - body_html, - status - ) VALUES ( - uuid_generate_v4(), - 'email', - 'system', - '$recipient', - 'Test Email from nself Notifications', - 'This is a test email to verify your notification system is working correctly.', - '

Test Email

This is a test email to verify your notification system is working correctly.

', - 'pending' - ) - RETURNING id; - " 2>&1) - - if [[ $? -eq 0 ]]; then - plugin_success "Test email queued" - printf "\n" - printf "Check the notification_queue table or run the worker to process:\n" - printf " nself plugin notifications worker\n" - printf "\n" - else - plugin_error "Failed to queue test email" - printf "%s\n" "$result" - return 1 - fi -} - -test_template() { - local template_name="${1:-welcome_email}" - local recipient="${2:-}" - - if [[ -z "$recipient" ]]; then - plugin_error "Recipient required" - printf "Usage: nself plugin notifications test template \n" - return 1 - fi - - plugin_info "Sending test using template: $template_name" - - # Check template exists - local template_exists - template_exists=$(plugin_db_query "SELECT COUNT(*) FROM notification_templates WHERE name = '$template_name';" 2>/dev/null | grep -o '[0-9]*' | head -1) - - if [[ "$template_exists" -eq 0 ]]; then - plugin_error "Template not found: $template_name" - printf "\nAvailable templates:\n" - plugin_db_query "SELECT name FROM notification_templates;" 2>/dev/null || true - return 1 - fi - - # Create notification from template - local result - result=$(plugin_db_query " - INSERT INTO notifications ( - user_id, - template_name, - channel, - category, - recipient_email, - subject, - body_text, - body_html, - status, - metadata - ) - SELECT - uuid_generate_v4(), - '$template_name', - 'email', - category, - '$recipient', - REPLACE(subject, '{{app_name}}', 'nself'), - REPLACE(REPLACE(body_text, '{{app_name}}', 'nself'), '{{user_name}}', 'Test User'), - REPLACE(REPLACE(body_html, '{{app_name}}', 'nself'), '{{user_name}}', 'Test User'), - 'pending', - '{\"test\": true}'::jsonb - FROM notification_templates - WHERE name = '$template_name' - RETURNING id; - " 2>&1) - - if [[ $? -eq 0 ]]; then - plugin_success "Test notification created from template" - printf "\n" - printf "Run worker to process:\n" - printf " nself plugin notifications worker\n" - printf "\n" - else - plugin_error "Failed to create notification" - printf "%s\n" "$result" - return 1 - fi -} - -test_providers() { - plugin_info "Testing provider connectivity..." - printf "\n" - - # List enabled providers - local providers - providers=$(plugin_db_query "SELECT name, type, health_status FROM notification_providers WHERE enabled = true;" 2>/dev/null) - - if [[ -z "$providers" ]]; then - plugin_warn "No providers enabled" - printf "\nConfigure a provider first:\n" - printf " NOTIFICATIONS_EMAIL_PROVIDER=resend\n" - printf " NOTIFICATIONS_EMAIL_API_KEY=re_xxx\n" - printf "\n" - return 1 - fi - - printf "Enabled providers:\n" - printf "%s\n" "$providers" - printf "\n" - - plugin_success "Provider check complete" -} - -# ============================================================================= -# Main -# ============================================================================= - -run_tests() { - local test_type="${1:-}" - shift || true - - case "$test_type" in - email) - test_email "$@" - ;; - template) - test_template "$@" - ;; - providers) - test_providers - ;; - *) - show_help - return 1 - ;; - esac -} - -# Show help -show_help() { - printf "Usage: nself plugin notifications test [args]\n\n" - printf "Send test notifications to verify system setup.\n\n" - printf "Test types:\n" - printf " email Send basic test email\n" - printf " template Test notification template\n" - printf " providers Check provider status\n\n" - printf "Examples:\n" - printf " nself plugin notifications test email user@example.com\n" - printf " nself plugin notifications test template welcome_email user@example.com\n" - printf " nself plugin notifications test providers\n" -} - -# Parse arguments -if [[ $# -eq 0 ]]; then - show_help - exit 1 -fi - -case "${1:-}" in - -h|--help|help) - show_help - ;; - *) - run_tests "$@" - ;; -esac diff --git a/free/notifications/actions/worker.sh b/free/notifications/actions/worker.sh deleted file mode 100755 index 61727f6e3..000000000 --- a/free/notifications/actions/worker.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Notifications Worker Action -# Background worker for processing notification queue -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Worker -# ============================================================================= - -start_worker() { - local concurrency="${WORKER_CONCURRENCY:-5}" - local poll_interval="${WORKER_POLL_INTERVAL:-1000}" - - plugin_info "Starting Notifications worker..." - plugin_info "Concurrency: $concurrency" - plugin_info "Poll interval: ${poll_interval}ms" - printf "\n" - - # Check if TypeScript implementation exists - if [[ -f "${PLUGIN_DIR}/ts/dist/worker.js" ]]; then - cd "${PLUGIN_DIR}/ts" - node dist/worker.js - elif [[ -f "${PLUGIN_DIR}/ts/src/worker.ts" ]]; then - cd "${PLUGIN_DIR}/ts" - if command -v tsx >/dev/null 2>&1; then - tsx src/worker.ts - elif command -v ts-node >/dev/null 2>&1; then - ts-node src/worker.ts - else - plugin_error "TypeScript runtime not found (tsx or ts-node required)" - printf "\nInstall with: npm install -g tsx\n" - return 1 - fi - else - plugin_error "Worker implementation not found" - printf "\n" - printf "Build the TypeScript implementation:\n" - printf " cd %s/ts\n" "$PLUGIN_DIR" - printf " npm install\n" - printf " npm run build\n" - printf "\n" - return 1 - fi -} - -# Show help -show_help() { - printf "Usage: nself plugin notifications worker [options]\n\n" - printf "Start the background worker for processing notification queue.\n\n" - printf "Options:\n" - printf " --concurrency N Number of concurrent workers (default: 5)\n" - printf " --poll-interval N Poll interval in ms (default: 1000)\n\n" - printf "Environment:\n" - printf " WORKER_CONCURRENCY Number of concurrent workers\n" - printf " WORKER_POLL_INTERVAL Poll interval in milliseconds\n" - printf " DATABASE_URL PostgreSQL connection string\n\n" - printf "The worker:\n" - printf " - Polls notification_queue for pending items\n" - printf " - Processes notifications via configured providers\n" - printf " - Handles retries with exponential backoff\n" - printf " - Updates status in real-time\n" -} - -# Parse arguments -while [[ $# -gt 0 ]]; do - case "$1" in - -h|--help|help) - show_help - exit 0 - ;; - --concurrency) - export WORKER_CONCURRENCY="$2" - shift 2 - ;; - --poll-interval) - export WORKER_POLL_INTERVAL="$2" - shift 2 - ;; - *) - plugin_error "Unknown option: $1" - show_help - exit 1 - ;; - esac -done - -start_worker diff --git a/free/notifications/install.sh b/free/notifications/install.sh deleted file mode 100755 index f880b5312..000000000 --- a/free/notifications/install.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Notifications Plugin Installer -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" -source "${SHARED_DIR}/schema-sync.sh" - -# ============================================================================= -# Installation -# ============================================================================= - -install_notifications_plugin() { - plugin_info "Installing Notifications plugin..." - - # Apply database schema - plugin_info "Applying database schema..." - - # Ensure migrations table exists - schema_ensure_migrations_table - - # Apply main schema - if [[ -f "${PLUGIN_DIR}/schema/tables.sql" ]]; then - plugin_db_exec_file "${PLUGIN_DIR}/schema/tables.sql" - fi - - # Apply migrations - if [[ -d "${PLUGIN_DIR}/schema/migrations" ]]; then - for migration in "${PLUGIN_DIR}/schema/migrations"/*.sql; do - [[ ! -f "$migration" ]] && continue - - local migration_name - migration_name=$(basename "$migration" .sql) - - if ! schema_migration_applied "notifications" "$migration_name"; then - plugin_info "Applying migration: $migration_name" - plugin_db_exec_file "$migration" - schema_record_migration "notifications" "$migration_name" - fi - done - fi - - # Create cache and log directories - mkdir -p "${HOME}/.nself/cache/plugins/notifications" - mkdir -p "${HOME}/.nself/logs/plugins/notifications" - mkdir -p "${HOME}/.nself/templates/notifications" - - # Copy default templates - if [[ -d "${PLUGIN_DIR}/templates" ]]; then - cp -r "${PLUGIN_DIR}/templates/"* "${HOME}/.nself/templates/notifications/" 2>/dev/null || true - fi - - plugin_success "Notifications plugin installed successfully!" - - printf "\n" - printf "Next steps:\n" - printf " 1. Configure email provider in .env (see .env.example)\n" - printf " 2. Run 'nself plugin notifications test' to verify setup\n" - printf " 3. Start notification server: 'nself plugin notifications server'\n" - printf " 4. Start worker for queue processing: 'nself plugin notifications worker'\n" - printf "\n" - printf "Available providers:\n" - printf " Email: Resend, SendGrid, Mailgun, AWS SES, SMTP\n" - printf " Push: FCM, OneSignal, Web Push\n" - printf " SMS: Twilio, Plivo, AWS SNS\n" - printf "\n" -} - -# Run installation -install_notifications_plugin diff --git a/free/notifications/uninstall.sh b/free/notifications/uninstall.sh deleted file mode 100755 index 3399869f3..000000000 --- a/free/notifications/uninstall.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Notifications Plugin Uninstaller -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Uninstallation -# ============================================================================= - -uninstall_notifications_plugin() { - plugin_info "Uninstalling Notifications plugin..." - - # Ask for confirmation - printf "\n" - plugin_warn "This will remove all notification data including:" - printf " - Notification templates\n" - printf " - User preferences\n" - printf " - Notification history\n" - printf " - Queue items\n" - printf " - Provider configurations\n" - printf "\n" - - printf "Are you sure you want to continue? [y/N] " - read -r REPLY - printf "\n" - - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - plugin_info "Uninstall cancelled" - return 0 - fi - - # Drop tables - plugin_info "Removing database tables..." - - plugin_db_query "DROP TABLE IF EXISTS notification_queue CASCADE;" || true - plugin_db_query "DROP TABLE IF EXISTS notifications CASCADE;" || true - plugin_db_query "DROP TABLE IF EXISTS notification_batches CASCADE;" || true - plugin_db_query "DROP TABLE IF EXISTS notification_providers CASCADE;" || true - plugin_db_query "DROP TABLE IF EXISTS notification_preferences CASCADE;" || true - plugin_db_query "DROP TABLE IF EXISTS notification_templates CASCADE;" || true - - # Drop views - plugin_db_query "DROP VIEW IF EXISTS notification_delivery_rates CASCADE;" || true - plugin_db_query "DROP VIEW IF EXISTS notification_engagement CASCADE;" || true - plugin_db_query "DROP VIEW IF EXISTS notification_provider_health CASCADE;" || true - plugin_db_query "DROP VIEW IF EXISTS notification_user_summary CASCADE;" || true - plugin_db_query "DROP VIEW IF EXISTS notification_queue_backlog CASCADE;" || true - - # Drop functions - plugin_db_query "DROP FUNCTION IF EXISTS get_user_notification_preference(UUID, VARCHAR, VARCHAR) CASCADE;" || true - plugin_db_query "DROP FUNCTION IF EXISTS check_notification_rate_limit(UUID, VARCHAR, INTEGER, INTEGER) CASCADE;" || true - - # Remove migration records - plugin_db_query "DELETE FROM plugin_migrations WHERE plugin_name = 'notifications';" || true - - # Clean up directories (optional - keep by default for safety) - # Uncomment to remove cache/logs on uninstall - # rm -rf "${HOME}/.nself/cache/plugins/notifications" - # rm -rf "${HOME}/.nself/logs/plugins/notifications" - # rm -rf "${HOME}/.nself/templates/notifications" - - plugin_success "Notifications plugin uninstalled successfully!" - - printf "\n" - printf "Note: Cache and log files were preserved at:\n" - printf " ${HOME}/.nself/cache/plugins/notifications\n" - printf " ${HOME}/.nself/logs/plugins/notifications\n" - printf " ${HOME}/.nself/templates/notifications\n" - printf "\n" - printf "To remove them manually:\n" - printf " rm -rf ~/.nself/cache/plugins/notifications\n" - printf " rm -rf ~/.nself/logs/plugins/notifications\n" - printf " rm -rf ~/.nself/templates/notifications\n" - printf "\n" -} - -# Run uninstallation -uninstall_notifications_plugin diff --git a/free/paypal/install.sh b/free/paypal/install.sh deleted file mode 100755 index 2720be2d5..000000000 --- a/free/paypal/install.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/bash -# ============================================================================= -# PayPal Plugin Installer -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" -source "${SHARED_DIR}/schema-sync.sh" - -# ============================================================================= -# Installation -# ============================================================================= - -install_paypal_plugin() { - plugin_info "Installing PayPal plugin..." - - # Check required environment variables - if [[ -z "${PAYPAL_CLIENT_ID:-}" && -z "${PAYPAL_CLIENT_IDS:-}" ]]; then - plugin_warn "PayPal client credentials not set. Configure PAYPAL_CLIENT_ID/PAYPAL_CLIENT_SECRET or PAYPAL_CLIENT_IDS/PAYPAL_CLIENT_SECRETS before using the plugin." - plugin_info "Add to your .env: PAYPAL_CLIENT_ID=... PAYPAL_CLIENT_SECRET=..." - fi - - # Apply database schema - plugin_info "Applying database schema..." - - # Ensure migrations table exists - schema_ensure_migrations_table - - # Apply main schema - if [[ -f "${PLUGIN_DIR}/schema/tables.sql" ]]; then - plugin_db_exec_file "${PLUGIN_DIR}/schema/tables.sql" - fi - - # Apply migrations - if [[ -d "${PLUGIN_DIR}/schema/migrations" ]]; then - for migration in "${PLUGIN_DIR}/schema/migrations"/*.sql; do - [[ ! -f "$migration" ]] && continue - - local migration_name - migration_name=$(basename "$migration" .sql) - - if ! schema_migration_applied "paypal" "$migration_name"; then - plugin_info "Applying migration: $migration_name" - plugin_db_exec_file "$migration" - schema_record_migration "paypal" "$migration_name" - fi - done - fi - - # Create cache and log directories - mkdir -p "${HOME}/.nself/cache/plugins/paypal" - mkdir -p "${HOME}/.nself/logs/plugins/paypal" - - # Webhook setup info - if [[ -n "${PAYPAL_WEBHOOK_ID:-}" ]]; then - plugin_info "Webhook ID configured" - else - plugin_info "To enable webhooks, set PAYPAL_WEBHOOK_ID in your .env" - plugin_info "Create a webhook at https://developer.paypal.com/dashboard/webhooks" - fi - - plugin_success "PayPal plugin installed successfully!" - - printf "\n" - printf "Next steps:\n" - printf " 1. Add PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET to your .env file\n" - printf " 2. Run 'nself plugin paypal sync' to import existing data\n" - printf " 3. Configure webhooks at https://developer.paypal.com/dashboard/webhooks\n" - printf "\n" -} - -# Run installation -install_paypal_plugin diff --git a/free/shopify/actions/customers.sh b/free/shopify/actions/customers.sh deleted file mode 100644 index dd8233b87..000000000 --- a/free/shopify/actions/customers.sh +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env bash -# Shopify Plugin - Customers Action - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -show_help() { - echo "nself plugin shopify customers - Customer management" - echo "" - echo "Usage: nself plugin shopify customers [subcommand] [options]" - echo "" - echo "Subcommands:" - echo " list List customers" - echo " show Show customer details" - echo " top Top customers by spending" - echo " stats Customer statistics" - echo "" -} - -list_customers() { - local limit=50 - - while [[ $# -gt 0 ]]; do - case "$1" in - --limit) limit="$2"; shift 2 ;; - *) shift ;; - esac - done - - printf "%-12s %-30s %-10s %-12s %-10s\n" "ID" "Email" "Orders" "Spent" "State" - printf "%-12s %-30s %-10s %-12s %-10s\n" "------------" "------------------------------" "----------" "------------" "----------" - - plugin_db_query "SELECT id, COALESCE(email, '-'), orders_count, total_spent, state - FROM shopify_customers - ORDER BY total_spent DESC - LIMIT $limit" | while IFS='|' read -r id email orders spent state; do - printf "%-12s %-30s %-10s $%-11s %-10s\n" "$id" "${email:0:30}" "$orders" "$spent" "$state" - done -} - -show_customer() { - local customer_id="$1" - - if [[ -z "$customer_id" ]]; then - plugin_log "error" "Customer ID required" - return 1 - fi - - echo "Customer Details" - echo "================" - plugin_db_query "SELECT - 'ID: ' || id, - 'Name: ' || COALESCE(first_name, '') || ' ' || COALESCE(last_name, ''), - 'Email: ' || COALESCE(email, 'n/a'), - 'Phone: ' || COALESCE(phone, 'n/a'), - 'State: ' || state, - 'Orders: ' || orders_count, - 'Total Spent: $' || total_spent, - 'Avg Order: $' || CASE WHEN orders_count > 0 THEN ROUND(total_spent / orders_count, 2) ELSE 0 END, - 'Accepts Marketing: ' || accepts_marketing, - 'Verified Email: ' || verified_email, - 'Tags: ' || COALESCE(tags, ''), - 'Customer Since: ' || created_at - FROM shopify_customers WHERE id = $customer_id" - - echo "" - echo "Recent Orders:" - plugin_db_query "SELECT name, total_price, financial_status, created_at - FROM shopify_orders - WHERE customer_id = $customer_id - ORDER BY created_at DESC - LIMIT 5" | while IFS='|' read -r name total status created; do - printf " %s - \$%s (%s) - %s\n" "$name" "$total" "$status" "${created:0:10}" - done -} - -show_top() { - echo "Top Customers by Spending" - echo "=========================" - echo "" - - printf "%-5s %-30s %-10s %-12s %-12s\n" "Rank" "Email" "Orders" "Total Spent" "Avg Order" - printf "%-5s %-30s %-10s %-12s %-12s\n" "-----" "------------------------------" "----------" "------------" "------------" - - local rank=1 - plugin_db_query "SELECT email, orders_count, total_spent, - CASE WHEN orders_count > 0 THEN ROUND(total_spent / orders_count, 2) ELSE 0 END - FROM shopify_customers - WHERE orders_count > 0 - ORDER BY total_spent DESC - LIMIT 20" | while IFS='|' read -r email orders spent avg; do - printf "%-5s %-30s %-10s $%-11s $%-11s\n" "$rank" "${email:0:30}" "$orders" "$spent" "$avg" - ((rank++)) - done -} - -show_stats() { - echo "Customer Statistics" - echo "===================" - echo "" - - echo "Summary:" - plugin_db_query "SELECT - 'Total Customers: ' || COUNT(*), - 'With Orders: ' || COUNT(*) FILTER (WHERE orders_count > 0), - 'Total Revenue: $' || COALESCE(SUM(total_spent), 0), - 'Avg Customer Value: $' || COALESCE(ROUND(AVG(total_spent) FILTER (WHERE orders_count > 0), 2), 0) - FROM shopify_customers" - - echo "" - echo "By State:" - plugin_db_query "SELECT state, COUNT(*) FROM shopify_customers GROUP BY state ORDER BY COUNT(*) DESC" | while IFS='|' read -r state count; do - printf " %-15s %s\n" "$state" "$count" - done - - echo "" - echo "Marketing Opt-in:" - plugin_db_query "SELECT accepts_marketing, COUNT(*) - FROM shopify_customers - GROUP BY accepts_marketing" | while IFS='|' read -r accepts count; do - local label="No" - [[ "$accepts" == "t" ]] && label="Yes" - printf " %-15s %s\n" "$label" "$count" - done - - echo "" - echo "Customer Segments:" - plugin_db_query "SELECT - CASE - WHEN total_spent >= 1000 THEN 'VIP ($1000+)' - WHEN total_spent >= 500 THEN 'High ($500-999)' - WHEN total_spent >= 100 THEN 'Medium ($100-499)' - WHEN total_spent > 0 THEN 'Low ($1-99)' - ELSE 'No purchases' - END AS segment, - COUNT(*) - FROM shopify_customers - GROUP BY segment - ORDER BY MIN(total_spent) DESC" | while IFS='|' read -r segment count; do - printf " %-20s %s\n" "$segment" "$count" - done -} - -main() { - local subcommand="${1:-list}" - shift 2>/dev/null || true - - case "$subcommand" in - list) list_customers "$@" ;; - show) show_customer "$@" ;; - top) show_top ;; - stats) show_stats ;; - -h|--help) show_help ;; - *) show_help; return 1 ;; - esac -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/free/shopify/actions/orders.sh b/free/shopify/actions/orders.sh deleted file mode 100644 index 37cb2d50b..000000000 --- a/free/shopify/actions/orders.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env bash -# Shopify Plugin - Orders Action - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -show_help() { - echo "nself plugin shopify orders - Order management" - echo "" - echo "Usage: nself plugin shopify orders [subcommand] [options]" - echo "" - echo "Subcommands:" - echo " list List orders" - echo " show Show order details" - echo " pending List pending orders" - echo " unfulfilled List unfulfilled orders" - echo " stats Order statistics" - echo "" - echo "Options:" - echo " --status Filter by financial status" - echo " --limit Limit results (default: 50)" - echo "" -} - -list_orders() { - local status="" - local fulfillment="" - local limit=50 - - while [[ $# -gt 0 ]]; do - case "$1" in - --status) status="$2"; shift 2 ;; - --fulfillment) fulfillment="$2"; shift 2 ;; - --limit) limit="$2"; shift 2 ;; - *) shift ;; - esac - done - - local where_clause="WHERE test = false" - [[ -n "$status" ]] && where_clause+=" AND financial_status = '$status'" - [[ -n "$fulfillment" ]] && where_clause+=" AND fulfillment_status = '$fulfillment'" - - printf "%-12s %-12s %-12s %-15s %-12s %-20s\n" "Order" "Name" "Total" "Payment" "Fulfillment" "Created" - printf "%-12s %-12s %-12s %-15s %-12s %-20s\n" "------------" "------------" "------------" "---------------" "------------" "--------------------" - - plugin_db_query "SELECT id, name, total_price, financial_status, COALESCE(fulfillment_status, 'unfulfilled'), created_at - FROM shopify_orders - $where_clause - ORDER BY created_at DESC - LIMIT $limit" | while IFS='|' read -r id name total payment fulfillment created; do - printf "%-12s %-12s $%-11s %-15s %-12s %-20s\n" "$id" "$name" "$total" "$payment" "$fulfillment" "${created:0:10}" - done -} - -show_order() { - local order_id="$1" - - if [[ -z "$order_id" ]]; then - plugin_log "error" "Order ID required" - return 1 - fi - - echo "Order Details" - echo "=============" - plugin_db_query "SELECT - 'Order: ' || name, - 'ID: ' || id, - 'Email: ' || COALESCE(email, 'n/a'), - 'Financial Status: ' || financial_status, - 'Fulfillment Status: ' || COALESCE(fulfillment_status, 'unfulfilled'), - 'Currency: ' || currency, - 'Subtotal: $' || subtotal_price, - 'Discounts: $' || COALESCE(total_discounts, '0'), - 'Tax: $' || COALESCE(total_tax, '0'), - 'Total: $' || total_price, - 'Gateway: ' || COALESCE(gateway, 'n/a'), - 'Created: ' || created_at - FROM shopify_orders WHERE id = $order_id OR order_number = $order_id" - - echo "" - echo "Line Items:" - plugin_db_query "SELECT - ' ' || quantity || 'x ' || title || ' @ $' || price || ' = $' || (quantity * price::numeric) - FROM shopify_order_items WHERE order_id = $order_id" - - echo "" - echo "Shipping Address:" - plugin_db_query "SELECT shipping_address::text FROM shopify_orders WHERE id = $order_id" | jq -r ' - if . then - " " + (.first_name // "") + " " + (.last_name // "") + "\n" + - " " + (.address1 // "") + "\n" + - (if .address2 then " " + .address2 + "\n" else "" end) + - " " + (.city // "") + ", " + (.province_code // "") + " " + (.zip // "") + "\n" + - " " + (.country // "") - else - " No shipping address" - end - ' 2>/dev/null || echo " No shipping address" -} - -show_pending() { - list_orders --status pending "$@" -} - -show_unfulfilled() { - list_orders --fulfillment unfulfilled "$@" -} - -show_stats() { - echo "Order Statistics" - echo "================" - echo "" - - echo "Summary (excluding test orders):" - plugin_db_query "SELECT - 'Total Orders: ' || COUNT(*), - 'Total Revenue: $' || COALESCE(SUM(total_price), 0), - 'Average Order: $' || COALESCE(ROUND(AVG(total_price), 2), 0) - FROM shopify_orders WHERE test = false AND financial_status = 'paid'" - - echo "" - echo "By Financial Status:" - plugin_db_query "SELECT financial_status, COUNT(*), SUM(total_price) - FROM shopify_orders - WHERE test = false - GROUP BY financial_status - ORDER BY COUNT(*) DESC" | while IFS='|' read -r status count total; do - printf " %-15s %5s orders ($%s)\n" "$status" "$count" "$total" - done - - echo "" - echo "By Fulfillment Status:" - plugin_db_query "SELECT COALESCE(fulfillment_status, 'unfulfilled'), COUNT(*) - FROM shopify_orders - WHERE test = false - GROUP BY fulfillment_status" | while IFS='|' read -r status count; do - printf " %-15s %s\n" "$status" "$count" - done - - echo "" - echo "Recent Daily Sales (Last 7 Days):" - plugin_db_query "SELECT DATE(created_at), COUNT(*), SUM(total_price) - FROM shopify_orders - WHERE test = false AND financial_status = 'paid' - AND created_at > NOW() - INTERVAL '7 days' - GROUP BY DATE(created_at) - ORDER BY DATE(created_at) DESC" | while IFS='|' read -r date count total; do - printf " %-12s %3s orders ($%s)\n" "$date" "$count" "$total" - done -} - -main() { - local subcommand="${1:-list}" - shift 2>/dev/null || true - - case "$subcommand" in - list) list_orders "$@" ;; - show) show_order "$@" ;; - pending) show_pending "$@" ;; - unfulfilled) show_unfulfilled "$@" ;; - stats) show_stats ;; - -h|--help) show_help ;; - *) show_help; return 1 ;; - esac -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/free/shopify/actions/products.sh b/free/shopify/actions/products.sh deleted file mode 100644 index a59643bcd..000000000 --- a/free/shopify/actions/products.sh +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env bash -# Shopify Plugin - Products Action - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -show_help() { - echo "nself plugin shopify products - Product management" - echo "" - echo "Usage: nself plugin shopify products [subcommand] [options]" - echo "" - echo "Subcommands:" - echo " list List products" - echo " show Show product details" - echo " stats Product statistics" - echo " low-stock Show low stock items" - echo "" - echo "Options:" - echo " --vendor Filter by vendor" - echo " --status Filter by status (active, archived, draft)" - echo " --limit Limit results (default: 50)" - echo "" -} - -list_products() { - local vendor="" - local status="" - local limit=50 - - while [[ $# -gt 0 ]]; do - case "$1" in - --vendor) vendor="$2"; shift 2 ;; - --status) status="$2"; shift 2 ;; - --limit) limit="$2"; shift 2 ;; - *) shift ;; - esac - done - - local where_clause="WHERE 1=1" - [[ -n "$vendor" ]] && where_clause+=" AND vendor = '$vendor'" - [[ -n "$status" ]] && where_clause+=" AND status = '$status'" - - printf "%-12s %-40s %-15s %-10s %-10s\n" "ID" "Title" "Vendor" "Status" "Variants" - printf "%-12s %-40s %-15s %-10s %-10s\n" "------------" "----------------------------------------" "---------------" "----------" "----------" - - plugin_db_query "SELECT p.id, SUBSTRING(p.title, 1, 40), COALESCE(p.vendor, '-'), p.status, - (SELECT COUNT(*) FROM shopify_variants v WHERE v.product_id = p.id) - FROM shopify_products p - $where_clause - ORDER BY p.updated_at DESC - LIMIT $limit" | while IFS='|' read -r id title vendor status variants; do - printf "%-12s %-40s %-15s %-10s %-10s\n" "$id" "$title" "$vendor" "$status" "$variants" - done -} - -show_product() { - local product_id="$1" - - if [[ -z "$product_id" ]]; then - plugin_log "error" "Product ID required" - return 1 - fi - - echo "Product Details" - echo "===============" - plugin_db_query "SELECT - 'ID: ' || id, - 'Title: ' || title, - 'Vendor: ' || COALESCE(vendor, 'n/a'), - 'Type: ' || COALESCE(product_type, 'n/a'), - 'Status: ' || status, - 'Handle: ' || handle, - 'Tags: ' || COALESCE(tags, ''), - 'Created: ' || created_at, - 'Updated: ' || updated_at - FROM shopify_products WHERE id = $product_id" - - echo "" - echo "Variants:" - plugin_db_query "SELECT - ' ' || title || ' - $' || price || ' (SKU: ' || COALESCE(sku, 'n/a') || ', Stock: ' || inventory_quantity || ')' - FROM shopify_variants WHERE product_id = $product_id ORDER BY position" -} - -show_stats() { - echo "Product Statistics" - echo "==================" - echo "" - - echo "By Status:" - plugin_db_query "SELECT status, COUNT(*) FROM shopify_products GROUP BY status ORDER BY COUNT(*) DESC" | while IFS='|' read -r status count; do - printf " %-15s %s\n" "$status" "$count" - done - - echo "" - echo "By Vendor (Top 10):" - plugin_db_query "SELECT COALESCE(vendor, 'No vendor'), COUNT(*) - FROM shopify_products - GROUP BY vendor - ORDER BY COUNT(*) DESC - LIMIT 10" | while IFS='|' read -r vendor count; do - printf " %-25s %s\n" "$vendor" "$count" - done - - echo "" - echo "Inventory Summary:" - plugin_db_query "SELECT - 'Total Products: ' || COUNT(DISTINCT product_id), - 'Total Variants: ' || COUNT(*), - 'Total Stock: ' || SUM(inventory_quantity), - 'Low Stock (<5): ' || COUNT(*) FILTER (WHERE inventory_quantity < 5), - 'Out of Stock: ' || COUNT(*) FILTER (WHERE inventory_quantity = 0) - FROM shopify_variants" -} - -show_low_stock() { - echo "Low Stock Items (< 5 units)" - echo "===========================" - echo "" - - printf "%-40s %-20s %-10s %-10s\n" "Product" "Variant" "SKU" "Stock" - printf "%-40s %-20s %-10s %-10s\n" "----------------------------------------" "--------------------" "----------" "----------" - - plugin_db_query "SELECT SUBSTRING(p.title, 1, 40), SUBSTRING(v.title, 1, 20), COALESCE(v.sku, '-'), v.inventory_quantity - FROM shopify_variants v - JOIN shopify_products p ON v.product_id = p.id - WHERE v.inventory_quantity < 5 - AND p.status = 'active' - ORDER BY v.inventory_quantity ASC - LIMIT 50" | while IFS='|' read -r product variant sku stock; do - printf "%-40s %-20s %-10s %-10s\n" "$product" "$variant" "$sku" "$stock" - done -} - -main() { - local subcommand="${1:-list}" - shift 2>/dev/null || true - - case "$subcommand" in - list) list_products "$@" ;; - show) show_product "$@" ;; - stats) show_stats ;; - low-stock) show_low_stock ;; - -h|--help) show_help ;; - *) show_help; return 1 ;; - esac -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/free/shopify/actions/sync.sh b/free/shopify/actions/sync.sh deleted file mode 100644 index f781e0301..000000000 --- a/free/shopify/actions/sync.sh +++ /dev/null @@ -1,376 +0,0 @@ -#!/usr/bin/env bash -# Shopify Plugin - Sync Action - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -# Configuration -API_VERSION="${SHOPIFY_API_VERSION:-2024-01}" -RATE_LIMIT_DELAY="${SHOPIFY_RATE_LIMIT_DELAY:-0.5}" - -get_store_domain() { - local store="$SHOPIFY_STORE" - [[ ! "$store" == *".myshopify.com" ]] && store="${store}.myshopify.com" - echo "$store" -} - -shopify_api() { - local endpoint="$1" - local store_domain - store_domain=$(get_store_domain) - - curl -s \ - -H "X-Shopify-Access-Token: $SHOPIFY_ACCESS_TOKEN" \ - -H "Content-Type: application/json" \ - "https://${store_domain}/admin/api/${API_VERSION}/${endpoint}" - - sleep "$RATE_LIMIT_DELAY" -} - -shopify_api_paginated() { - local endpoint="$1" - local results="[]" - local page_info="" - local url="$endpoint" - - while true; do - local response - response=$(shopify_api "$url") - - local data_key - data_key=$(echo "$endpoint" | sed 's/\.json.*//' | sed 's/.*\///') - local items - items=$(echo "$response" | jq ".${data_key} // []") - - results=$(echo "$results $items" | jq -s 'add') - - # Check for pagination link - local next_link - next_link=$(echo "$response" | jq -r '.next_page_info // empty') - if [[ -z "$next_link" ]]; then - break - fi - url="${endpoint}?page_info=${next_link}" - done - - echo "$results" -} - -sync_shop() { - plugin_log "info" "Syncing shop info..." - - local shop - shop=$(shopify_api "shop.json") - local shop_data - shop_data=$(echo "$shop" | jq '.shop') - - if [[ -z "$shop_data" ]] || [[ "$shop_data" == "null" ]]; then - plugin_log "error" "Failed to fetch shop info" - return 1 - fi - - local shop_id - shop_id=$(echo "$shop_data" | jq -r '.id') - - plugin_db_query "INSERT INTO shopify_shops ( - id, name, email, domain, myshopify_domain, shop_owner, - phone, address1, city, province, country, zip, - currency, timezone, iana_timezone, plan_name, weight_unit, - created_at, updated_at - ) VALUES ( - $(echo "$shop_data" | jq '.id'), - $(echo "$shop_data" | jq '.name'), - $(echo "$shop_data" | jq '.email'), - $(echo "$shop_data" | jq '.domain'), - $(echo "$shop_data" | jq '.myshopify_domain'), - $(echo "$shop_data" | jq '.shop_owner'), - $(echo "$shop_data" | jq '.phone'), - $(echo "$shop_data" | jq '.address1'), - $(echo "$shop_data" | jq '.city'), - $(echo "$shop_data" | jq '.province'), - $(echo "$shop_data" | jq '.country'), - $(echo "$shop_data" | jq '.zip'), - $(echo "$shop_data" | jq '.currency'), - $(echo "$shop_data" | jq '.timezone'), - $(echo "$shop_data" | jq '.iana_timezone'), - $(echo "$shop_data" | jq '.plan_name'), - $(echo "$shop_data" | jq '.weight_unit'), - $(echo "$shop_data" | jq '.created_at'), - $(echo "$shop_data" | jq '.updated_at') - ) ON CONFLICT (id) DO UPDATE SET - name = EXCLUDED.name, - email = EXCLUDED.email, - plan_name = EXCLUDED.plan_name, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - - echo "$shop_id" -} - -sync_products() { - local shop_id="$1" - plugin_log "info" "Syncing products..." - - local products - products=$(shopify_api_paginated "products.json?limit=250") - - local count - count=$(echo "$products" | jq 'length') - plugin_log "info" "Found $count products" - - echo "$products" | jq -c '.[]' | while read -r product; do - local product_id - product_id=$(echo "$product" | jq -r '.id') - - plugin_db_query "INSERT INTO shopify_products ( - id, shop_id, title, body_html, vendor, product_type, handle, - status, tags, images, options, published_at, created_at, updated_at - ) VALUES ( - $(echo "$product" | jq '.id'), - $shop_id, - $(echo "$product" | jq '.title'), - $(echo "$product" | jq '.body_html'), - $(echo "$product" | jq '.vendor'), - $(echo "$product" | jq '.product_type'), - $(echo "$product" | jq '.handle'), - $(echo "$product" | jq '.status'), - $(echo "$product" | jq '.tags'), - $(echo "$product" | jq '.images'), - $(echo "$product" | jq '.options'), - $(echo "$product" | jq '.published_at'), - $(echo "$product" | jq '.created_at'), - $(echo "$product" | jq '.updated_at') - ) ON CONFLICT (id) DO UPDATE SET - title = EXCLUDED.title, - body_html = EXCLUDED.body_html, - vendor = EXCLUDED.vendor, - status = EXCLUDED.status, - tags = EXCLUDED.tags, - images = EXCLUDED.images, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - - # Sync variants - echo "$product" | jq -c '.variants[]' 2>/dev/null | while read -r variant; do - plugin_db_query "INSERT INTO shopify_variants ( - id, product_id, title, price, compare_at_price, sku, barcode, - position, grams, weight, weight_unit, inventory_item_id, - inventory_quantity, inventory_policy, inventory_management, - requires_shipping, taxable, option1, option2, option3, - created_at, updated_at - ) VALUES ( - $(echo "$variant" | jq '.id'), - $product_id, - $(echo "$variant" | jq '.title'), - $(echo "$variant" | jq '.price'), - $(echo "$variant" | jq '.compare_at_price'), - $(echo "$variant" | jq '.sku'), - $(echo "$variant" | jq '.barcode'), - $(echo "$variant" | jq '.position'), - $(echo "$variant" | jq '.grams'), - $(echo "$variant" | jq '.weight'), - $(echo "$variant" | jq '.weight_unit'), - $(echo "$variant" | jq '.inventory_item_id'), - $(echo "$variant" | jq '.inventory_quantity'), - $(echo "$variant" | jq '.inventory_policy'), - $(echo "$variant" | jq '.inventory_management'), - $(echo "$variant" | jq '.requires_shipping'), - $(echo "$variant" | jq '.taxable'), - $(echo "$variant" | jq '.option1'), - $(echo "$variant" | jq '.option2'), - $(echo "$variant" | jq '.option3'), - $(echo "$variant" | jq '.created_at'), - $(echo "$variant" | jq '.updated_at') - ) ON CONFLICT (id) DO UPDATE SET - title = EXCLUDED.title, - price = EXCLUDED.price, - inventory_quantity = EXCLUDED.inventory_quantity, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - done - done - - plugin_log "success" "Products synced" -} - -sync_customers() { - local shop_id="$1" - plugin_log "info" "Syncing customers..." - - local customers - customers=$(shopify_api_paginated "customers.json?limit=250") - - local count - count=$(echo "$customers" | jq 'length') - plugin_log "info" "Found $count customers" - - echo "$customers" | jq -c '.[]' | while read -r customer; do - plugin_db_query "INSERT INTO shopify_customers ( - id, shop_id, email, first_name, last_name, phone, - verified_email, accepts_marketing, orders_count, total_spent, - state, note, tags, default_address, addresses, - created_at, updated_at - ) VALUES ( - $(echo "$customer" | jq '.id'), - $shop_id, - $(echo "$customer" | jq '.email'), - $(echo "$customer" | jq '.first_name'), - $(echo "$customer" | jq '.last_name'), - $(echo "$customer" | jq '.phone'), - $(echo "$customer" | jq '.verified_email'), - $(echo "$customer" | jq '.accepts_marketing'), - $(echo "$customer" | jq '.orders_count'), - $(echo "$customer" | jq '.total_spent'), - $(echo "$customer" | jq '.state'), - $(echo "$customer" | jq '.note'), - $(echo "$customer" | jq '.tags'), - $(echo "$customer" | jq '.default_address'), - $(echo "$customer" | jq '.addresses'), - $(echo "$customer" | jq '.created_at'), - $(echo "$customer" | jq '.updated_at') - ) ON CONFLICT (id) DO UPDATE SET - email = EXCLUDED.email, - orders_count = EXCLUDED.orders_count, - total_spent = EXCLUDED.total_spent, - state = EXCLUDED.state, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - done - - plugin_log "success" "Customers synced" -} - -sync_orders() { - local shop_id="$1" - plugin_log "info" "Syncing orders..." - - local orders - orders=$(shopify_api_paginated "orders.json?status=any&limit=250") - - local count - count=$(echo "$orders" | jq 'length') - plugin_log "info" "Found $count orders" - - echo "$orders" | jq -c '.[]' | while read -r order; do - local order_id - order_id=$(echo "$order" | jq -r '.id') - local customer_id - customer_id=$(echo "$order" | jq -r '.customer.id // "null"') - - plugin_db_query "INSERT INTO shopify_orders ( - id, shop_id, order_number, name, email, phone, - customer_id, financial_status, fulfillment_status, - currency, subtotal_price, total_discounts, total_price, - total_tax, total_weight, discount_codes, note, tags, - gateway, source_name, billing_address, shipping_address, - shipping_lines, processed_at, created_at, updated_at - ) VALUES ( - $(echo "$order" | jq '.id'), - $shop_id, - $(echo "$order" | jq '.order_number'), - $(echo "$order" | jq '.name'), - $(echo "$order" | jq '.email'), - $(echo "$order" | jq '.phone'), - $(if [[ "$customer_id" != "null" ]]; then echo "$customer_id"; else echo "NULL"; fi), - $(echo "$order" | jq '.financial_status'), - $(echo "$order" | jq '.fulfillment_status'), - $(echo "$order" | jq '.currency'), - $(echo "$order" | jq '.subtotal_price'), - $(echo "$order" | jq '.total_discounts'), - $(echo "$order" | jq '.total_price'), - $(echo "$order" | jq '.total_tax'), - $(echo "$order" | jq '.total_weight'), - $(echo "$order" | jq '.discount_codes'), - $(echo "$order" | jq '.note'), - $(echo "$order" | jq '.tags'), - $(echo "$order" | jq '.gateway'), - $(echo "$order" | jq '.source_name'), - $(echo "$order" | jq '.billing_address'), - $(echo "$order" | jq '.shipping_address'), - $(echo "$order" | jq '.shipping_lines'), - $(echo "$order" | jq '.processed_at'), - $(echo "$order" | jq '.created_at'), - $(echo "$order" | jq '.updated_at') - ) ON CONFLICT (id) DO UPDATE SET - financial_status = EXCLUDED.financial_status, - fulfillment_status = EXCLUDED.fulfillment_status, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - - # Sync line items - echo "$order" | jq -c '.line_items[]' 2>/dev/null | while read -r item; do - plugin_db_query "INSERT INTO shopify_order_items ( - id, order_id, product_id, variant_id, title, variant_title, - sku, vendor, quantity, price, total_discount, - fulfillment_status, fulfillable_quantity, grams, - requires_shipping, taxable, gift_card - ) VALUES ( - $(echo "$item" | jq '.id'), - $order_id, - $(echo "$item" | jq '.product_id'), - $(echo "$item" | jq '.variant_id'), - $(echo "$item" | jq '.title'), - $(echo "$item" | jq '.variant_title'), - $(echo "$item" | jq '.sku'), - $(echo "$item" | jq '.vendor'), - $(echo "$item" | jq '.quantity'), - $(echo "$item" | jq '.price'), - $(echo "$item" | jq '.total_discount'), - $(echo "$item" | jq '.fulfillment_status'), - $(echo "$item" | jq '.fulfillable_quantity'), - $(echo "$item" | jq '.grams'), - $(echo "$item" | jq '.requires_shipping'), - $(echo "$item" | jq '.taxable'), - $(echo "$item" | jq '.gift_card') - ) ON CONFLICT (id) DO UPDATE SET - fulfillment_status = EXCLUDED.fulfillment_status, - fulfillable_quantity = EXCLUDED.fulfillable_quantity, - synced_at = NOW()" - done - done - - plugin_log "success" "Orders synced" -} - -main() { - local initial=false - local products_only=false - - while [[ $# -gt 0 ]]; do - case "$1" in - --initial) initial=true; shift ;; - --products-only) products_only=true; shift ;; - *) shift ;; - esac - done - - plugin_log "info" "Starting Shopify sync..." - - if [[ -z "${SHOPIFY_STORE:-}" ]] || [[ -z "${SHOPIFY_ACCESS_TOKEN:-}" ]]; then - plugin_log "error" "SHOPIFY_STORE and SHOPIFY_ACCESS_TOKEN required" - return 1 - fi - - local shop_id - shop_id=$(sync_shop) - - if [[ -z "$shop_id" ]]; then - plugin_log "error" "Failed to sync shop" - return 1 - fi - - sync_products "$shop_id" - - if [[ "$products_only" != "true" ]]; then - sync_customers "$shop_id" - sync_orders "$shop_id" - fi - - plugin_set_meta "shopify" "last_sync" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" - plugin_log "success" "Shopify sync complete" - return 0 -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/free/shopify/actions/webhook.sh b/free/shopify/actions/webhook.sh deleted file mode 100644 index c2130cc59..000000000 --- a/free/shopify/actions/webhook.sh +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env bash -# Shopify Plugin - Webhook Management Action - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -show_help() { - echo "nself plugin shopify webhook - Webhook event management" - echo "" - echo "Usage: nself plugin shopify webhook [subcommand] [options]" - echo "" - echo "Subcommands:" - echo " list List received webhook events" - echo " show Show event details" - echo " pending List unprocessed events" - echo " retry Retry processing an event" - echo " stats Webhook statistics" - echo "" -} - -list_events() { - local topic="" - local limit=50 - - while [[ $# -gt 0 ]]; do - case "$1" in - --topic) topic="$2"; shift 2 ;; - --limit) limit="$2"; shift 2 ;; - *) shift ;; - esac - done - - local where_clause="WHERE 1=1" - [[ -n "$topic" ]] && where_clause+=" AND topic = '$topic'" - - printf "%-36s %-25s %-10s %-20s\n" "ID" "Topic" "Processed" "Received" - printf "%-36s %-25s %-10s %-20s\n" "------------------------------------" "-------------------------" "----------" "--------------------" - - plugin_db_query "SELECT id, topic, processed, received_at - FROM shopify_webhook_events - $where_clause - ORDER BY received_at DESC - LIMIT $limit" | while IFS='|' read -r id topic processed received; do - local proc_str="No" - [[ "$processed" == "t" ]] && proc_str="Yes" - printf "%-36s %-25s %-10s %-20s\n" "${id:0:36}" "$topic" "$proc_str" "${received:0:19}" - done -} - -show_event() { - local event_id="$1" - - if [[ -z "$event_id" ]]; then - plugin_log "error" "Event ID required" - return 1 - fi - - echo "Webhook Event Details" - echo "=====================" - - plugin_db_query "SELECT - 'ID: ' || id, - 'Topic: ' || topic, - 'Shop: ' || COALESCE(shop_domain, 'n/a'), - 'Processed: ' || processed, - 'Processed At: ' || COALESCE(processed_at::text, 'n/a'), - 'Error: ' || COALESCE(error, 'none'), - 'Received: ' || received_at - FROM shopify_webhook_events - WHERE id = '$event_id'" -} - -list_pending() { - list_events --processed false "$@" -} - -retry_event() { - local event_id="$1" - - if [[ -z "$event_id" ]]; then - plugin_log "error" "Event ID required" - return 1 - fi - - plugin_log "info" "Retrying event: $event_id" - plugin_db_query "UPDATE shopify_webhook_events SET processed = false, error = NULL WHERE id = '$event_id'" - - if [[ -x "${PLUGIN_DIR}/webhooks/handler.sh" ]]; then - bash "${PLUGIN_DIR}/webhooks/handler.sh" "$event_id" - plugin_log "success" "Event reprocessed" - else - plugin_log "error" "Webhook handler not found" - return 1 - fi -} - -show_stats() { - echo "Webhook Statistics" - echo "==================" - echo "" - - echo "By Topic:" - plugin_db_query "SELECT topic, COUNT(*), COUNT(*) FILTER (WHERE processed = true) - FROM shopify_webhook_events - GROUP BY topic - ORDER BY COUNT(*) DESC" | while IFS='|' read -r topic total processed; do - printf " %-25s %5s total, %5s processed\n" "$topic" "$total" "$processed" - done -} - -main() { - local subcommand="${1:-list}" - shift 2>/dev/null || true - - case "$subcommand" in - list) list_events "$@" ;; - show) show_event "$@" ;; - pending) list_pending "$@" ;; - retry) retry_event "$@" ;; - stats) show_stats ;; - -h|--help) show_help ;; - *) show_help; return 1 ;; - esac -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/free/shopify/install.sh b/free/shopify/install.sh deleted file mode 100644 index c55b38850..000000000 --- a/free/shopify/install.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env bash -# Shopify Plugin - Installation Script - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PLUGIN_NAME="shopify" - -# Source shared utilities -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -# ============================================================================ -# Pre-flight Checks -# ============================================================================ - -preflight_checks() { - plugin_log "info" "Running pre-flight checks..." - - # Check required environment variables - if [[ -z "${SHOPIFY_STORE:-}" ]]; then - plugin_log "error" "SHOPIFY_STORE is required" - plugin_log "info" "Format: your-store.myshopify.com or just 'your-store'" - return 1 - fi - - if [[ -z "${SHOPIFY_ACCESS_TOKEN:-}" ]]; then - plugin_log "error" "SHOPIFY_ACCESS_TOKEN is required" - plugin_log "info" "Create a custom app in your Shopify admin to get an access token" - return 1 - fi - - # Normalize store domain - local store_domain="$SHOPIFY_STORE" - if [[ ! "$store_domain" == *".myshopify.com" ]]; then - store_domain="${store_domain}.myshopify.com" - fi - - # Test API access - plugin_log "info" "Testing Shopify API access..." - local api_version="${SHOPIFY_API_VERSION:-2024-01}" - local response - response=$(curl -s -o /dev/null -w "%{http_code}" \ - -H "X-Shopify-Access-Token: $SHOPIFY_ACCESS_TOKEN" \ - "https://${store_domain}/admin/api/${api_version}/shop.json") - - if [[ "$response" != "200" ]]; then - plugin_log "error" "Shopify API returned status $response" - plugin_log "info" "Check your SHOPIFY_STORE and SHOPIFY_ACCESS_TOKEN" - return 1 - fi - - plugin_log "success" "Shopify API access verified" - return 0 -} - -# ============================================================================ -# Database Setup -# ============================================================================ - -setup_database() { - plugin_log "info" "Setting up database schema..." - - local tables_exist - tables_exist=$(plugin_db_query "SELECT COUNT(*) FROM information_schema.tables WHERE table_name LIKE 'shopify_%'" 2>/dev/null || echo "0") - - if [[ "$tables_exist" -gt 0 ]]; then - plugin_log "info" "Shopify tables already exist, checking for updates..." - return 0 - fi - - if [[ -f "${PLUGIN_DIR}/schema/tables.sql" ]]; then - plugin_log "info" "Applying database schema..." - plugin_db_exec_file "${PLUGIN_DIR}/schema/tables.sql" - plugin_log "success" "Database schema applied" - else - plugin_log "error" "Schema file not found" - return 1 - fi - - return 0 -} - -# ============================================================================ -# Webhook Setup -# ============================================================================ - -setup_webhooks() { - plugin_log "info" "Configuring webhook handlers..." - - local webhook_path="${SHOPIFY_WEBHOOK_PATH:-/webhooks/shopify}" - plugin_log "info" "Webhook endpoint: $webhook_path" - - if [[ -n "${SHOPIFY_WEBHOOK_SECRET:-}" ]]; then - plugin_log "success" "Webhook signature verification enabled" - else - plugin_log "warning" "SHOPIFY_WEBHOOK_SECRET not set" - fi - - local webhook_runtime_dir="${HOME}/.nself/plugins/${PLUGIN_NAME}/webhooks" - mkdir -p "$webhook_runtime_dir" - - if [[ -d "${PLUGIN_DIR}/webhooks" ]]; then - cp -r "${PLUGIN_DIR}/webhooks/"* "$webhook_runtime_dir/" 2>/dev/null || true - chmod +x "$webhook_runtime_dir"/*.sh 2>/dev/null || true - chmod +x "$webhook_runtime_dir/events/"*.sh 2>/dev/null || true - fi - - return 0 -} - -# ============================================================================ -# Initial Sync -# ============================================================================ - -initial_sync() { - plugin_log "info" "Running initial data sync..." - - if [[ -x "${PLUGIN_DIR}/actions/sync.sh" ]]; then - bash "${PLUGIN_DIR}/actions/sync.sh" --initial - else - plugin_log "warning" "Sync action not found, skipping initial sync" - fi - - return 0 -} - -# ============================================================================ -# Main Installation -# ============================================================================ - -main() { - plugin_log "info" "Installing Shopify plugin..." - - if ! preflight_checks; then - plugin_log "error" "Pre-flight checks failed" - return 1 - fi - - if ! setup_database; then - plugin_log "error" "Database setup failed" - return 1 - fi - - if ! setup_webhooks; then - plugin_log "error" "Webhook setup failed" - return 1 - fi - - if [[ "${SKIP_INITIAL_SYNC:-}" != "true" ]]; then - initial_sync || plugin_log "warning" "Initial sync had issues, continuing..." - fi - - plugin_mark_installed "$PLUGIN_NAME" - - plugin_log "success" "Shopify plugin installed successfully" - - echo "" - echo "Usage:" - echo " nself plugin shopify sync - Sync store data" - echo " nself plugin shopify products - List products" - echo " nself plugin shopify orders - View orders" - echo " nself plugin shopify customers - View customers" - echo " nself plugin shopify inventory - Check inventory" - echo "" - - return 0 -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/free/shopify/uninstall.sh b/free/shopify/uninstall.sh deleted file mode 100644 index 857034175..000000000 --- a/free/shopify/uninstall.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -# Shopify Plugin - Uninstall Script - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PLUGIN_NAME="shopify" - -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -remove_database() { - local keep_data="${1:-false}" - - if [[ "$keep_data" == "true" ]]; then - plugin_log "info" "Keeping database tables (--keep-data specified)" - return 0 - fi - - plugin_log "warning" "Removing Shopify database tables..." - - # Drop views first - plugin_db_query "DROP VIEW IF EXISTS shopify_customer_value CASCADE" 2>/dev/null || true - plugin_db_query "DROP VIEW IF EXISTS shopify_low_inventory CASCADE" 2>/dev/null || true - plugin_db_query "DROP VIEW IF EXISTS shopify_top_products CASCADE" 2>/dev/null || true - plugin_db_query "DROP VIEW IF EXISTS shopify_sales_overview CASCADE" 2>/dev/null || true - - # Drop tables in reverse dependency order - plugin_db_query "DROP TABLE IF EXISTS shopify_webhook_events CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS shopify_inventory CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS shopify_order_items CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS shopify_orders CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS shopify_customers CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS shopify_collections CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS shopify_variants CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS shopify_products CASCADE" 2>/dev/null || true - plugin_db_query "DROP TABLE IF EXISTS shopify_shops CASCADE" 2>/dev/null || true - - plugin_log "success" "Database tables removed" - return 0 -} - -remove_webhooks() { - plugin_log "info" "Removing webhook handlers..." - local webhook_runtime_dir="${HOME}/.nself/plugins/${PLUGIN_NAME}/webhooks" - [[ -d "$webhook_runtime_dir" ]] && rm -rf "$webhook_runtime_dir" - plugin_log "success" "Webhook handlers removed" - return 0 -} - -remove_cache() { - plugin_log "info" "Clearing plugin cache..." - local cache_dir="${HOME}/.nself/cache/plugins/${PLUGIN_NAME}" - [[ -d "$cache_dir" ]] && rm -rf "$cache_dir" - plugin_log "success" "Cache cleared" - return 0 -} - -main() { - local keep_data=false - - while [[ $# -gt 0 ]]; do - case "$1" in - --keep-data) keep_data=true; shift ;; - *) shift ;; - esac - done - - plugin_log "info" "Uninstalling Shopify plugin..." - - remove_database "$keep_data" - remove_webhooks - remove_cache - plugin_mark_uninstalled "$PLUGIN_NAME" - - plugin_log "success" "Shopify plugin uninstalled" - return 0 -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@" diff --git a/free/shopify/webhooks/events/default.sh b/free/shopify/webhooks/events/default.sh deleted file mode 100644 index 587646ac8..000000000 --- a/free/shopify/webhooks/events/default.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Shopify Webhook Handler - Default Handler - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -handle_default() { - local event_id="$1" - local topic="$2" - local payload="$3" - - plugin_log "debug" "Default handler for topic: $topic" - return 0 -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && handle_default "$@" diff --git a/free/shopify/webhooks/events/orders_create.sh b/free/shopify/webhooks/events/orders_create.sh deleted file mode 100644 index 6fb85929d..000000000 --- a/free/shopify/webhooks/events/orders_create.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -# Shopify Webhook Handler - orders/create - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -handle_order_create() { - local event_id="$1" - local payload="$2" - - plugin_log "info" "Handling orders/create event" - - local order_id order_name total - order_id=$(echo "$payload" | jq -r '.id') - order_name=$(echo "$payload" | jq -r '.name') - total=$(echo "$payload" | jq -r '.total_price') - - plugin_log "info" "New order: $order_name (\$$total)" - - # Upsert order - local customer_id - customer_id=$(echo "$payload" | jq -r '.customer.id // "null"') - - plugin_db_query "INSERT INTO shopify_orders ( - id, order_number, name, email, phone, customer_id, - financial_status, fulfillment_status, currency, - subtotal_price, total_discounts, total_price, total_tax, - discount_codes, note, tags, gateway, source_name, - billing_address, shipping_address, shipping_lines, - processed_at, created_at, updated_at - ) VALUES ( - $(echo "$payload" | jq '.id'), - $(echo "$payload" | jq '.order_number'), - $(echo "$payload" | jq '.name'), - $(echo "$payload" | jq '.email'), - $(echo "$payload" | jq '.phone'), - $(if [[ "$customer_id" != "null" ]]; then echo "$customer_id"; else echo "NULL"; fi), - $(echo "$payload" | jq '.financial_status'), - $(echo "$payload" | jq '.fulfillment_status'), - $(echo "$payload" | jq '.currency'), - $(echo "$payload" | jq '.subtotal_price'), - $(echo "$payload" | jq '.total_discounts'), - $(echo "$payload" | jq '.total_price'), - $(echo "$payload" | jq '.total_tax'), - $(echo "$payload" | jq '.discount_codes'), - $(echo "$payload" | jq '.note'), - $(echo "$payload" | jq '.tags'), - $(echo "$payload" | jq '.gateway'), - $(echo "$payload" | jq '.source_name'), - $(echo "$payload" | jq '.billing_address'), - $(echo "$payload" | jq '.shipping_address'), - $(echo "$payload" | jq '.shipping_lines'), - $(echo "$payload" | jq '.processed_at'), - $(echo "$payload" | jq '.created_at'), - $(echo "$payload" | jq '.updated_at') - ) ON CONFLICT (id) DO UPDATE SET - financial_status = EXCLUDED.financial_status, - fulfillment_status = EXCLUDED.fulfillment_status, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - - # Insert line items - echo "$payload" | jq -c '.line_items[]' 2>/dev/null | while read -r item; do - plugin_db_query "INSERT INTO shopify_order_items ( - id, order_id, product_id, variant_id, title, variant_title, - sku, vendor, quantity, price, total_discount, - fulfillment_status, fulfillable_quantity - ) VALUES ( - $(echo "$item" | jq '.id'), - $order_id, - $(echo "$item" | jq '.product_id'), - $(echo "$item" | jq '.variant_id'), - $(echo "$item" | jq '.title'), - $(echo "$item" | jq '.variant_title'), - $(echo "$item" | jq '.sku'), - $(echo "$item" | jq '.vendor'), - $(echo "$item" | jq '.quantity'), - $(echo "$item" | jq '.price'), - $(echo "$item" | jq '.total_discount'), - $(echo "$item" | jq '.fulfillment_status'), - $(echo "$item" | jq '.fulfillable_quantity') - ) ON CONFLICT (id) DO NOTHING" - done - - plugin_log "success" "Order created: $order_name" - return 0 -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && handle_order_create "$@" diff --git a/free/shopify/webhooks/events/products_update.sh b/free/shopify/webhooks/events/products_update.sh deleted file mode 100644 index 04c1d4325..000000000 --- a/free/shopify/webhooks/events/products_update.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# Shopify Webhook Handler - products/update - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -handle_product_update() { - local event_id="$1" - local payload="$2" - - plugin_log "info" "Handling products/update event" - - local product_id title - product_id=$(echo "$payload" | jq -r '.id') - title=$(echo "$payload" | jq -r '.title') - - plugin_log "debug" "Product updated: $title ($product_id)" - - # Update product - plugin_db_query "UPDATE shopify_products SET - title = $(echo "$payload" | jq '.title'), - body_html = $(echo "$payload" | jq '.body_html'), - vendor = $(echo "$payload" | jq '.vendor'), - product_type = $(echo "$payload" | jq '.product_type'), - status = $(echo "$payload" | jq '.status'), - tags = $(echo "$payload" | jq '.tags'), - images = $(echo "$payload" | jq '.images'), - options = $(echo "$payload" | jq '.options'), - updated_at = $(echo "$payload" | jq '.updated_at'), - synced_at = NOW() - WHERE id = $product_id" - - # Update variants - echo "$payload" | jq -c '.variants[]' 2>/dev/null | while read -r variant; do - plugin_db_query "INSERT INTO shopify_variants ( - id, product_id, title, price, compare_at_price, sku, barcode, - position, grams, weight, weight_unit, inventory_item_id, - inventory_quantity, inventory_policy, requires_shipping, taxable, - option1, option2, option3, created_at, updated_at - ) VALUES ( - $(echo "$variant" | jq '.id'), - $product_id, - $(echo "$variant" | jq '.title'), - $(echo "$variant" | jq '.price'), - $(echo "$variant" | jq '.compare_at_price'), - $(echo "$variant" | jq '.sku'), - $(echo "$variant" | jq '.barcode'), - $(echo "$variant" | jq '.position'), - $(echo "$variant" | jq '.grams'), - $(echo "$variant" | jq '.weight'), - $(echo "$variant" | jq '.weight_unit'), - $(echo "$variant" | jq '.inventory_item_id'), - $(echo "$variant" | jq '.inventory_quantity'), - $(echo "$variant" | jq '.inventory_policy'), - $(echo "$variant" | jq '.requires_shipping'), - $(echo "$variant" | jq '.taxable'), - $(echo "$variant" | jq '.option1'), - $(echo "$variant" | jq '.option2'), - $(echo "$variant" | jq '.option3'), - $(echo "$variant" | jq '.created_at'), - $(echo "$variant" | jq '.updated_at') - ) ON CONFLICT (id) DO UPDATE SET - title = EXCLUDED.title, - price = EXCLUDED.price, - compare_at_price = EXCLUDED.compare_at_price, - sku = EXCLUDED.sku, - inventory_quantity = EXCLUDED.inventory_quantity, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()" - done - - plugin_log "success" "Product updated: $title" - return 0 -} - -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && handle_product_update "$@" diff --git a/free/shopify/webhooks/handler.sh b/free/shopify/webhooks/handler.sh deleted file mode 100644 index 4cee0a40a..000000000 --- a/free/shopify/webhooks/handler.sh +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env bash -# Shopify Plugin - Webhook Handler - -set -e - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "${PLUGIN_DIR}/../../shared/plugin-utils.sh" - -verify_signature() { - local payload="$1" - local signature="$2" - - if [[ -z "${SHOPIFY_WEBHOOK_SECRET:-}" ]]; then - plugin_log "warning" "SHOPIFY_WEBHOOK_SECRET not set, skipping verification" - return 0 - fi - - if [[ -z "$signature" ]]; then - plugin_log "error" "No signature provided" - return 1 - fi - - local expected - expected=$(echo -n "$payload" | openssl dgst -sha256 -hmac "$SHOPIFY_WEBHOOK_SECRET" -binary | base64) - - if [[ "$signature" != "$expected" ]]; then - plugin_log "error" "Invalid webhook signature" - return 1 - fi - - return 0 -} - -process_event() { - local event_id="$1" - local topic="$2" - local payload="$3" - - plugin_log "info" "Processing event: $topic" - - # Convert topic to filename (orders/create -> orders_create) - local handler_name - handler_name=$(echo "$topic" | tr '/' '_') - local handler_script="${PLUGIN_DIR}/webhooks/events/${handler_name}.sh" - local default_handler="${PLUGIN_DIR}/webhooks/events/default.sh" - - if [[ -x "$handler_script" ]]; then - bash "$handler_script" "$event_id" "$payload" - elif [[ -x "$default_handler" ]]; then - bash "$default_handler" "$event_id" "$topic" "$payload" - else - plugin_log "warning" "No handler for topic: $topic" - fi - - plugin_db_query "UPDATE shopify_webhook_events SET processed = true, processed_at = NOW() WHERE id = '$event_id'" - return 0 -} - -handle_webhook() { - local webhook_id="$1" - local topic="$2" - local signature="$3" - local payload="$4" - - # If called with just event ID, load from database - if [[ -n "$webhook_id" ]] && [[ -z "$topic" ]]; then - local event_data - event_data=$(plugin_db_query "SELECT topic, data FROM shopify_webhook_events WHERE id = '$webhook_id'" 2>/dev/null) - if [[ -n "$event_data" ]]; then - topic=$(echo "$event_data" | cut -d'|' -f1) - payload=$(echo "$event_data" | cut -d'|' -f2) - process_event "$webhook_id" "$topic" "$payload" - return $? - fi - fi - - if ! verify_signature "$payload" "$signature"; then - return 1 - fi - - local shop_id shop_domain - shop_id=$(echo "$payload" | jq -r '.id // empty') - shop_domain="${HTTP_X_SHOPIFY_SHOP_DOMAIN:-}" - - plugin_db_query "INSERT INTO shopify_webhook_events ( - id, topic, shop_id, shop_domain, data, received_at - ) VALUES ( - '$webhook_id', - '$topic', - $(if [[ -n "$shop_id" ]]; then echo "'$shop_id'"; else echo "NULL"; fi), - $(if [[ -n "$shop_domain" ]]; then echo "'$shop_domain'"; else echo "NULL"; fi), - '$(echo "$payload" | jq -c .)', - NOW() - ) ON CONFLICT (id) DO NOTHING" - - process_event "$webhook_id" "$topic" "$payload" - return 0 -} - -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - if [[ $# -ge 1 ]]; then - handle_webhook "$@" - else - webhook_id="${HTTP_X_SHOPIFY_WEBHOOK_ID:-$(uuidgen)}" - topic="${HTTP_X_SHOPIFY_TOPIC:-}" - signature="${HTTP_X_SHOPIFY_HMAC_SHA256:-}" - payload=$(cat) - - if [[ -n "$topic" ]]; then - handle_webhook "$webhook_id" "$topic" "$signature" "$payload" - else - echo "Error: Missing X-Shopify-Topic header" >&2 - exit 1 - fi - fi -fi diff --git a/free/stripe/actions/customers.sh b/free/stripe/actions/customers.sh deleted file mode 100644 index 2f5fa2518..000000000 --- a/free/stripe/actions/customers.sh +++ /dev/null @@ -1,194 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe Customers Action -# View and manage synced customer data -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Commands -# ============================================================================= - -list_customers() { - local limit="${1:-20}" - local offset="${2:-0}" - - printf "\n=== Stripe Customers ===\n\n" - - plugin_db_query " - SELECT - id, - COALESCE(email, '-') AS email, - COALESCE(name, '-') AS name, - COALESCE(currency, '-') AS currency, - balance, - CASE WHEN delinquent THEN 'Yes' ELSE 'No' END AS delinquent, - to_char(created_at, 'YYYY-MM-DD') AS created - FROM stripe_customers - WHERE deleted_at IS NULL - ORDER BY created_at DESC - LIMIT $limit OFFSET $offset; - " -} - -get_customer() { - local customer_id="$1" - - printf "\n=== Customer: %s ===\n\n" "$customer_id" - - # Customer details - plugin_db_query " - SELECT - id, - email, - name, - phone, - description, - currency, - balance, - delinquent, - created_at, - synced_at - FROM stripe_customers - WHERE id = '$customer_id'; - " - - # Subscriptions - printf "\n--- Subscriptions ---\n" - plugin_db_query " - SELECT - id, - status, - current_period_start, - current_period_end, - cancel_at_period_end - FROM stripe_subscriptions - WHERE customer_id = '$customer_id' - ORDER BY created_at DESC; - " - - # Recent invoices - printf "\n--- Recent Invoices ---\n" - plugin_db_query " - SELECT - id, - status, - amount_due, - amount_paid, - currency, - created_at - FROM stripe_invoices - WHERE customer_id = '$customer_id' - ORDER BY created_at DESC - LIMIT 5; - " -} - -search_customers() { - local query="$1" - - printf "\n=== Search: %s ===\n\n" "$query" - - plugin_db_query " - SELECT - id, - email, - name, - currency, - created_at - FROM stripe_customers - WHERE deleted_at IS NULL - AND ( - email ILIKE '%${query}%' - OR name ILIKE '%${query}%' - OR id ILIKE '%${query}%' - ) - ORDER BY created_at DESC - LIMIT 20; - " -} - -count_customers() { - printf "\n=== Customer Statistics ===\n\n" - - printf "Total customers: " - plugin_db_query "SELECT COUNT(*) FROM stripe_customers WHERE deleted_at IS NULL;" - - printf "With subscriptions: " - plugin_db_query "SELECT COUNT(DISTINCT customer_id) FROM stripe_subscriptions WHERE status IN ('active', 'trialing');" - - printf "Delinquent: " - plugin_db_query "SELECT COUNT(*) FROM stripe_customers WHERE delinquent = TRUE AND deleted_at IS NULL;" - - printf "\nBy currency:\n" - plugin_db_query " - SELECT - COALESCE(currency, 'unknown') AS currency, - COUNT(*) AS count - FROM stripe_customers - WHERE deleted_at IS NULL - GROUP BY currency - ORDER BY count DESC; - " -} - -# ============================================================================= -# Main -# ============================================================================= - -show_help() { - printf "Usage: nself plugin stripe customers [args]\n\n" - printf "Commands:\n" - printf " list [limit] [offset] List customers (default: 20)\n" - printf " get Get customer details\n" - printf " search Search customers by email, name, or ID\n" - printf " count Show customer statistics\n\n" - printf "Examples:\n" - printf " nself plugin stripe customers list\n" - printf " nself plugin stripe customers get cus_abc123\n" - printf " nself plugin stripe customers search john@example.com\n" -} - -main() { - local command="${1:-list}" - shift || true - - case "$command" in - list) - list_customers "${1:-20}" "${2:-0}" - ;; - get) - if [[ -z "${1:-}" ]]; then - plugin_error "Customer ID required" - return 1 - fi - get_customer "$1" - ;; - search) - if [[ -z "${1:-}" ]]; then - plugin_error "Search query required" - return 1 - fi - search_customers "$1" - ;; - count|stats) - count_customers - ;; - -h|--help|help) - show_help - ;; - *) - plugin_error "Unknown command: $command" - show_help - return 1 - ;; - esac -} - -main "$@" diff --git a/free/stripe/actions/invoices.sh b/free/stripe/actions/invoices.sh deleted file mode 100644 index fc4ac9d26..000000000 --- a/free/stripe/actions/invoices.sh +++ /dev/null @@ -1,212 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe Invoices Action -# View synced invoice data -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Commands -# ============================================================================= - -list_invoices() { - local status="${1:-all}" - local limit="${2:-20}" - - printf "\n=== Stripe Invoices (%s) ===\n\n" "$status" - - local status_filter="" - if [[ "$status" != "all" ]]; then - status_filter="AND i.status = '$status'" - fi - - plugin_db_query " - SELECT - i.id, - c.email AS customer, - i.status, - i.amount_due / 100.0 AS amount_due, - i.amount_paid / 100.0 AS amount_paid, - i.currency, - CASE WHEN i.paid THEN 'Yes' ELSE 'No' END AS paid, - to_char(i.created_at, 'YYYY-MM-DD') AS created - FROM stripe_invoices i - JOIN stripe_customers c ON i.customer_id = c.id - WHERE 1=1 $status_filter - ORDER BY i.created_at DESC - LIMIT $limit; - " -} - -get_invoice() { - local invoice_id="$1" - - printf "\n=== Invoice: %s ===\n\n" "$invoice_id" - - plugin_db_query " - SELECT - i.id, - i.number, - i.status, - c.id AS customer_id, - c.email AS customer_email, - c.name AS customer_name, - i.amount_due / 100.0 AS amount_due, - i.amount_paid / 100.0 AS amount_paid, - i.amount_remaining / 100.0 AS amount_remaining, - i.currency, - i.paid, - i.billing_reason, - i.subscription_id, - i.period_start, - i.period_end, - i.due_date, - i.hosted_invoice_url, - i.created_at, - i.synced_at - FROM stripe_invoices i - JOIN stripe_customers c ON i.customer_id = c.id - WHERE i.id = '$invoice_id'; - " -} - -invoice_stats() { - printf "\n=== Invoice Statistics ===\n\n" - - printf "By status:\n" - plugin_db_query " - SELECT - status, - COUNT(*) AS count, - SUM(amount_due) / 100.0 AS total_due, - SUM(amount_paid) / 100.0 AS total_paid - FROM stripe_invoices - GROUP BY status - ORDER BY count DESC; - " - - printf "\nLast 30 days:\n" - plugin_db_query " - SELECT - COUNT(*) AS invoices, - SUM(amount_paid) / 100.0 AS revenue, - SUM(CASE WHEN paid THEN 1 ELSE 0 END)::float / NULLIF(COUNT(*), 0) * 100 AS paid_pct - FROM stripe_invoices - WHERE created_at > NOW() - INTERVAL '30 days'; - " - - printf "\nOverdue (open & past due date):\n" - plugin_db_query " - SELECT - COUNT(*) AS count, - SUM(amount_remaining) / 100.0 AS total_outstanding - FROM stripe_invoices - WHERE status = 'open' - AND due_date < NOW(); - " -} - -recent_payments() { - local limit="${1:-10}" - - printf "\n=== Recent Paid Invoices ===\n\n" - - plugin_db_query " - SELECT - i.id, - c.email AS customer, - i.amount_paid / 100.0 AS amount, - i.currency, - to_char(i.created_at, 'YYYY-MM-DD HH24:MI') AS paid_at - FROM stripe_invoices i - JOIN stripe_customers c ON i.customer_id = c.id - WHERE i.paid = TRUE - ORDER BY i.created_at DESC - LIMIT $limit; - " -} - -failed_payments() { - local limit="${1:-10}" - - printf "\n=== Failed/Uncollectible Invoices ===\n\n" - - plugin_db_query " - SELECT - i.id, - c.email AS customer, - i.amount_due / 100.0 AS amount, - i.currency, - i.status, - i.attempt_count AS attempts, - to_char(i.created_at, 'YYYY-MM-DD') AS created - FROM stripe_invoices i - JOIN stripe_customers c ON i.customer_id = c.id - WHERE i.status IN ('open', 'uncollectible') - AND i.paid = FALSE - ORDER BY i.amount_due DESC - LIMIT $limit; - " -} - -# ============================================================================= -# Main -# ============================================================================= - -show_help() { - printf "Usage: nself plugin stripe invoices [args]\n\n" - printf "Commands:\n" - printf " list [status] [limit] List invoices (default: all, 20)\n" - printf " get Get invoice details\n" - printf " stats Show invoice statistics\n" - printf " recent [limit] Show recent paid invoices\n" - printf " failed [limit] Show failed/outstanding invoices\n\n" - printf "Status values: draft, open, paid, uncollectible, void, all\n\n" - printf "Examples:\n" - printf " nself plugin stripe invoices list paid 50\n" - printf " nself plugin stripe invoices get in_abc123\n" - printf " nself plugin stripe invoices failed\n" -} - -main() { - local command="${1:-list}" - shift || true - - case "$command" in - list) - list_invoices "${1:-all}" "${2:-20}" - ;; - get) - if [[ -z "${1:-}" ]]; then - plugin_error "Invoice ID required" - return 1 - fi - get_invoice "$1" - ;; - stats|statistics) - invoice_stats - ;; - recent) - recent_payments "${1:-10}" - ;; - failed|outstanding) - failed_payments "${1:-10}" - ;; - -h|--help|help) - show_help - ;; - *) - plugin_error "Unknown command: $command" - show_help - return 1 - ;; - esac -} - -main "$@" diff --git a/free/stripe/actions/subscriptions.sh b/free/stripe/actions/subscriptions.sh deleted file mode 100644 index a43968c0f..000000000 --- a/free/stripe/actions/subscriptions.sh +++ /dev/null @@ -1,192 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe Subscriptions Action -# View and manage synced subscription data -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Commands -# ============================================================================= - -list_subscriptions() { - local status="${1:-active}" - local limit="${2:-20}" - - printf "\n=== Stripe Subscriptions (%s) ===\n\n" "$status" - - local status_filter="" - if [[ "$status" != "all" ]]; then - status_filter="AND s.status = '$status'" - fi - - plugin_db_query " - SELECT - s.id, - c.email AS customer, - s.status, - to_char(s.current_period_start, 'YYYY-MM-DD') AS period_start, - to_char(s.current_period_end, 'YYYY-MM-DD') AS period_end, - CASE WHEN s.cancel_at_period_end THEN 'Yes' ELSE 'No' END AS canceling - FROM stripe_subscriptions s - JOIN stripe_customers c ON s.customer_id = c.id - WHERE 1=1 $status_filter - ORDER BY s.created_at DESC - LIMIT $limit; - " -} - -get_subscription() { - local sub_id="$1" - - printf "\n=== Subscription: %s ===\n\n" "$sub_id" - - plugin_db_query " - SELECT - s.id, - s.status, - c.id AS customer_id, - c.email AS customer_email, - c.name AS customer_name, - s.current_period_start, - s.current_period_end, - s.cancel_at_period_end, - s.canceled_at, - s.ended_at, - s.trial_start, - s.trial_end, - s.created_at, - s.synced_at - FROM stripe_subscriptions s - JOIN stripe_customers c ON s.customer_id = c.id - WHERE s.id = '$sub_id'; - " - - # Related invoices - printf "\n--- Related Invoices ---\n" - plugin_db_query " - SELECT - id, - status, - amount_due, - amount_paid, - currency, - paid, - created_at - FROM stripe_invoices - WHERE subscription_id = '$sub_id' - ORDER BY created_at DESC - LIMIT 10; - " -} - -subscription_stats() { - printf "\n=== Subscription Statistics ===\n\n" - - printf "By status:\n" - plugin_db_query " - SELECT - status, - COUNT(*) AS count - FROM stripe_subscriptions - GROUP BY status - ORDER BY count DESC; - " - - printf "\nCanceling at period end:\n" - plugin_db_query " - SELECT COUNT(*) AS count - FROM stripe_subscriptions - WHERE cancel_at_period_end = TRUE - AND status IN ('active', 'trialing'); - " - - printf "\nTrials ending this week:\n" - plugin_db_query " - SELECT COUNT(*) AS count - FROM stripe_subscriptions - WHERE status = 'trialing' - AND trial_end BETWEEN NOW() AND NOW() + INTERVAL '7 days'; - " - - printf "\nExpiring this month:\n" - plugin_db_query " - SELECT COUNT(*) AS count - FROM stripe_subscriptions - WHERE status IN ('active', 'trialing') - AND current_period_end BETWEEN NOW() AND NOW() + INTERVAL '30 days'; - " -} - -mrr_report() { - printf "\n=== Monthly Recurring Revenue ===\n\n" - - printf "Active MRR (estimated from subscription count):\n" - plugin_db_query " - SELECT - COUNT(*) AS active_subscriptions, - (SELECT COUNT(*) FROM stripe_customers WHERE deleted_at IS NULL) AS total_customers - FROM stripe_subscriptions - WHERE status IN ('active', 'trialing'); - " - - printf "\nNote: For accurate MRR, run 'nself plugin stripe sync' to get full pricing data.\n" -} - -# ============================================================================= -# Main -# ============================================================================= - -show_help() { - printf "Usage: nself plugin stripe subscriptions [args]\n\n" - printf "Commands:\n" - printf " list [status] [limit] List subscriptions (default: active, 20)\n" - printf " get Get subscription details\n" - printf " stats Show subscription statistics\n" - printf " mrr Show MRR report\n\n" - printf "Status values: active, trialing, past_due, canceled, all\n\n" - printf "Examples:\n" - printf " nself plugin stripe subscriptions list\n" - printf " nself plugin stripe subscriptions list all 50\n" - printf " nself plugin stripe subscriptions get sub_abc123\n" -} - -main() { - local command="${1:-list}" - shift || true - - case "$command" in - list) - list_subscriptions "${1:-active}" "${2:-20}" - ;; - get) - if [[ -z "${1:-}" ]]; then - plugin_error "Subscription ID required" - return 1 - fi - get_subscription "$1" - ;; - stats|statistics) - subscription_stats - ;; - mrr|revenue) - mrr_report - ;; - -h|--help|help) - show_help - ;; - *) - plugin_error "Unknown command: $command" - show_help - return 1 - ;; - esac -} - -main "$@" diff --git a/free/stripe/actions/sync.sh b/free/stripe/actions/sync.sh deleted file mode 100644 index cfe5ed948..000000000 --- a/free/stripe/actions/sync.sh +++ /dev/null @@ -1,381 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe Sync Action -# Sync all Stripe data to local database -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Configuration -# ============================================================================= - -STRIPE_API_KEY="${STRIPE_API_KEY:-}" -STRIPE_API_KEYS="${STRIPE_API_KEYS:-}" -STRIPE_ACCOUNT_LABELS="${STRIPE_ACCOUNT_LABELS:-}" -STRIPE_ACCOUNT_ID="${STRIPE_ACCOUNT_ID:-primary}" -STRIPE_API_VERSION="${STRIPE_API_VERSION:-2024-12-18.acacia}" -STRIPE_API_BASE="https://api.stripe.com/v1" -CURRENT_ACCOUNT_LABEL="primary" - -split_csv_lines() { - local input="$1" - [[ -z "$input" ]] && return 0 - - local IFS=',' - read -r -a raw_items <<< "$input" - - for item in "${raw_items[@]}"; do - item="$(printf '%s' "$item" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" - [[ -n "$item" ]] && printf '%s\n' "$item" - done -} - -scoped_info() { - local message="$1" - plugin_info "[$CURRENT_ACCOUNT_LABEL] $message" -} - -scoped_success() { - local message="$1" - plugin_success "[$CURRENT_ACCOUNT_LABEL] $message" -} - -current_account_label_sql() { - printf '%s' "$CURRENT_ACCOUNT_LABEL" | sed "s/'/''/g" -} - -# ============================================================================= -# API Functions -# ============================================================================= - -stripe_api() { - local endpoint="$1" - local params="${2:-}" - - local url="${STRIPE_API_BASE}/${endpoint}" - if [[ -n "$params" ]]; then - url="${url}?${params}" - fi - - curl -s \ - -H "Authorization: Bearer ${STRIPE_API_KEY}" \ - -H "Stripe-Version: ${STRIPE_API_VERSION}" \ - "$url" -} - -stripe_list_all() { - local endpoint="$1" - local params="${2:-limit=100}" - local callback="$3" - - local has_more="true" - local starting_after="" - local total=0 - - while [[ "$has_more" == "true" ]]; do - local request_params="$params" - if [[ -n "$starting_after" ]]; then - request_params="${request_params}&starting_after=${starting_after}" - fi - - local response - response=$(stripe_api "$endpoint" "$request_params") - - # Check for error - if printf '%s' "$response" | grep -q '"error"'; then - plugin_error "API error: $(plugin_json_get "$response" "message")" - return 1 - fi - - # Process data - local data - data=$(printf '%s' "$response" | grep -o '"data":\[[^]]*\]' | sed 's/"data"://') - - if [[ -n "$data" && "$data" != "[]" ]]; then - $callback "$data" - - # Get count - local count - count=$(printf '%s' "$data" | grep -o '"id"' | wc -l) - total=$((total + count)) - - # Get last ID for pagination - starting_after=$(printf '%s' "$data" | grep -o '"id":"[^"]*"' | tail -1 | sed 's/"id":"\([^"]*\)"/\1/') - fi - - # Check if there's more - has_more=$(printf '%s' "$response" | grep -o '"has_more":[a-z]*' | sed 's/"has_more"://') - [[ "$has_more" != "true" ]] && break - done - - printf '%s' "$total" -} - -# ============================================================================= -# Sync Functions -# ============================================================================= - -sync_customers() { - scoped_info "Syncing customers..." - - process_customers() { - local data="$1" - - # Process each customer - printf '%s' "$data" | tr '}' '\n' | while read -r customer; do - [[ -z "$customer" ]] && continue - - local id email name phone description currency balance created - - id=$(plugin_json_get "$customer" "id") - [[ -z "$id" ]] && continue - - email=$(plugin_json_get "$customer" "email") - name=$(plugin_json_get "$customer" "name") - phone=$(plugin_json_get "$customer" "phone") - description=$(plugin_json_get "$customer" "description") - currency=$(plugin_json_get "$customer" "currency") - balance=$(printf '%s' "$customer" | grep -o '"balance":[0-9-]*' | sed 's/"balance"://' || echo "0") - created=$(printf '%s' "$customer" | grep -o '"created":[0-9]*' | sed 's/"created"://') - - # Escape for SQL - email=$(printf '%s' "$email" | sed "s/'/''/g") - name=$(printf '%s' "$name" | sed "s/'/''/g") - description=$(printf '%s' "$description" | sed "s/'/''/g") - - plugin_db_query " - INSERT INTO stripe_customers (id, email, name, phone, description, currency, balance, created_at, source_account_id, synced_at) - VALUES ('$id', $([ -n "$email" ] && echo "'$email'" || echo "NULL"), $([ -n "$name" ] && echo "'$name'" || echo "NULL"), $([ -n "$phone" ] && echo "'$phone'" || echo "NULL"), $([ -n "$description" ] && echo "'$description'" || echo "NULL"), $([ -n "$currency" ] && echo "'$currency'" || echo "NULL"), ${balance:-0}, to_timestamp(${created:-0}), '$(current_account_label_sql)', NOW()) - ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name, phone = EXCLUDED.phone, description = EXCLUDED.description, currency = EXCLUDED.currency, balance = EXCLUDED.balance, source_account_id = EXCLUDED.source_account_id, synced_at = NOW(); - " >/dev/null 2>&1 || true - done - } - - local count - count=$(stripe_list_all "customers" "limit=100" process_customers) - scoped_success "Synced $count customers" -} - -sync_products() { - scoped_info "Syncing products..." - - process_products() { - local data="$1" - - printf '%s' "$data" | tr '}' '\n' | while read -r product; do - [[ -z "$product" ]] && continue - - local id name description active created - - id=$(plugin_json_get "$product" "id") - [[ -z "$id" ]] && continue - - name=$(plugin_json_get "$product" "name") - description=$(plugin_json_get "$product" "description") - active=$(printf '%s' "$product" | grep -o '"active":[a-z]*' | sed 's/"active"://' || echo "true") - created=$(printf '%s' "$product" | grep -o '"created":[0-9]*' | sed 's/"created"://') - - name=$(printf '%s' "$name" | sed "s/'/''/g") - description=$(printf '%s' "$description" | sed "s/'/''/g") - - plugin_db_query " - INSERT INTO stripe_products (id, name, description, active, created_at, source_account_id, synced_at) - VALUES ('$id', '$name', $([ -n "$description" ] && echo "'$description'" || echo "NULL"), $active, to_timestamp(${created:-0}), '$(current_account_label_sql)', NOW()) - ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, active = EXCLUDED.active, source_account_id = EXCLUDED.source_account_id, synced_at = NOW(); - " >/dev/null 2>&1 || true - done - } - - local count - count=$(stripe_list_all "products" "limit=100&active=true" process_products) - scoped_success "Synced $count products" -} - -sync_prices() { - scoped_info "Syncing prices..." - - process_prices() { - local data="$1" - - printf '%s' "$data" | tr '}' '\n' | while read -r price; do - [[ -z "$price" ]] && continue - - local id product_id currency unit_amount type active created - - id=$(plugin_json_get "$price" "id") - [[ -z "$id" ]] && continue - - product_id=$(plugin_json_get "$price" "product") - currency=$(plugin_json_get "$price" "currency") - unit_amount=$(printf '%s' "$price" | grep -o '"unit_amount":[0-9]*' | sed 's/"unit_amount"://' || echo "0") - type=$(plugin_json_get "$price" "type") - active=$(printf '%s' "$price" | grep -o '"active":[a-z]*' | sed 's/"active"://' || echo "true") - created=$(printf '%s' "$price" | grep -o '"created":[0-9]*' | sed 's/"created"://') - - plugin_db_query " - INSERT INTO stripe_prices (id, product_id, currency, unit_amount, type, active, created_at, source_account_id, synced_at) - VALUES ('$id', '$product_id', '$currency', ${unit_amount:-0}, '${type:-one_time}', $active, to_timestamp(${created:-0}), '$(current_account_label_sql)', NOW()) - ON CONFLICT (id) DO UPDATE SET product_id = EXCLUDED.product_id, currency = EXCLUDED.currency, unit_amount = EXCLUDED.unit_amount, type = EXCLUDED.type, active = EXCLUDED.active, source_account_id = EXCLUDED.source_account_id, synced_at = NOW(); - " >/dev/null 2>&1 || true - done - } - - local count - count=$(stripe_list_all "prices" "limit=100&active=true" process_prices) - scoped_success "Synced $count prices" -} - -sync_subscriptions() { - scoped_info "Syncing subscriptions..." - - process_subscriptions() { - local data="$1" - - printf '%s' "$data" | tr '}' '\n' | while read -r sub; do - [[ -z "$sub" ]] && continue - - local id customer_id status current_period_start current_period_end cancel_at_period_end created - - id=$(plugin_json_get "$sub" "id") - [[ -z "$id" ]] && continue - - customer_id=$(plugin_json_get "$sub" "customer") - status=$(plugin_json_get "$sub" "status") - current_period_start=$(printf '%s' "$sub" | grep -o '"current_period_start":[0-9]*' | sed 's/"current_period_start"://') - current_period_end=$(printf '%s' "$sub" | grep -o '"current_period_end":[0-9]*' | sed 's/"current_period_end"://') - cancel_at_period_end=$(printf '%s' "$sub" | grep -o '"cancel_at_period_end":[a-z]*' | sed 's/"cancel_at_period_end"://' || echo "false") - created=$(printf '%s' "$sub" | grep -o '"created":[0-9]*' | sed 's/"created"://') - - plugin_db_query " - INSERT INTO stripe_subscriptions (id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, items, created_at, source_account_id, synced_at) - VALUES ('$id', '$customer_id', '$status', to_timestamp(${current_period_start:-0}), to_timestamp(${current_period_end:-0}), $cancel_at_period_end, '[]'::jsonb, to_timestamp(${created:-0}), '$(current_account_label_sql)', NOW()) - ON CONFLICT (id) DO UPDATE SET customer_id = EXCLUDED.customer_id, status = EXCLUDED.status, current_period_start = EXCLUDED.current_period_start, current_period_end = EXCLUDED.current_period_end, cancel_at_period_end = EXCLUDED.cancel_at_period_end, source_account_id = EXCLUDED.source_account_id, synced_at = NOW(); - " >/dev/null 2>&1 || true - done - } - - local count - count=$(stripe_list_all "subscriptions" "limit=100" process_subscriptions) - scoped_success "Synced $count subscriptions" -} - -# ============================================================================= -# Main -# ============================================================================= - -run_sync_target() { - local target="$1" - - case "$target" in - all) - sync_customers - sync_products - sync_prices - sync_subscriptions - ;; - customers) - sync_customers - ;; - products) - sync_products - ;; - prices) - sync_prices - ;; - subscriptions) - sync_subscriptions - ;; - *) - plugin_error "Unknown sync target: $target" - printf "\nAvailable targets: all, customers, products, prices, subscriptions\n" - return 1 - ;; - esac - - return 0 -} - -sync_all() { - local target="${1:-all}" - local stripe_api_keys=() - local account_labels=() - - while IFS= read -r item; do - stripe_api_keys+=("$item") - done < <(split_csv_lines "$STRIPE_API_KEYS") - - while IFS= read -r item; do - account_labels+=("$item") - done < <(split_csv_lines "$STRIPE_ACCOUNT_LABELS") - - # Single-account fallback - if [[ "${#stripe_api_keys[@]}" -eq 0 ]]; then - if [[ -z "$STRIPE_API_KEY" ]]; then - plugin_error "Stripe API credentials are not set" - printf "\nSet it in your .env file:\n" - printf " STRIPE_API_KEY=sk_live_...\n" - printf " or\n" - printf " STRIPE_API_KEYS=sk_live_legacy,sk_live_rebrand\n\n" - return 1 - fi - stripe_api_keys=("$STRIPE_API_KEY") - account_labels=("$STRIPE_ACCOUNT_ID") - fi - - if [[ "${#account_labels[@]}" -gt 0 && "${#account_labels[@]}" -ne "${#stripe_api_keys[@]}" ]]; then - plugin_error "STRIPE_ACCOUNT_LABELS must match STRIPE_API_KEYS count" - printf "\nSet it in your .env file:\n" - printf " STRIPE_API_KEYS=sk_live_legacy,sk_live_rebrand\n" - printf " STRIPE_ACCOUNT_LABELS=legacy,rebrand\n\n" - return 1 - fi - - plugin_info "Starting Stripe data sync across ${#stripe_api_keys[@]} account(s)..." - printf "\n" - - local idx=0 - for api_key in "${stripe_api_keys[@]}"; do - idx=$((idx + 1)) - CURRENT_ACCOUNT_LABEL="${account_labels[$((idx - 1))]:-account-${idx}}" - STRIPE_API_KEY="$api_key" - - scoped_info "Starting sync target '${target}'" - run_sync_target "$target" || return 1 - printf "\n" - done - - plugin_success "Stripe sync complete across ${#stripe_api_keys[@]} account(s)!" -} - -# Show help -show_help() { - printf "Usage: nself plugin stripe sync [target]\n\n" - printf "Sync Stripe data to local database.\n\n" - printf "Targets:\n" - printf " all Sync all data (default)\n" - printf " customers Sync customers only\n" - printf " products Sync products only\n" - printf " prices Sync prices only\n" - printf " subscriptions Sync subscriptions only\n\n" - printf "Environment:\n" - printf " STRIPE_API_KEY Your Stripe API key (required if STRIPE_API_KEYS is unset)\n" - printf " STRIPE_API_KEYS Comma-separated Stripe API keys for unified multi-account sync (optional)\n" - printf " STRIPE_ACCOUNT_LABELS Comma-separated labels for STRIPE_API_KEYS (optional)\n" - printf " STRIPE_ACCOUNT_ID Label for single-account sync output (optional, default: primary)\n" -} - -# Parse arguments -case "${1:-}" in - -h|--help|help) - show_help - ;; - *) - sync_all "${1:-all}" - ;; -esac diff --git a/free/stripe/actions/webhook.sh b/free/stripe/actions/webhook.sh deleted file mode 100644 index 03cec6ab0..000000000 --- a/free/stripe/actions/webhook.sh +++ /dev/null @@ -1,249 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe Webhook Action -# Manage webhook configuration and view event history -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Commands -# ============================================================================= - -show_status() { - printf "\n=== Stripe Webhook Status ===\n\n" - - printf "Configuration:\n" - printf " Webhook Secret: %s\n" "$([[ -n "${STRIPE_WEBHOOK_SECRET:-}" ]] && echo "Configured" || echo "Not set")" - printf " Endpoint Path: /webhooks/stripe\n" - - printf "\nRecent events:\n" - plugin_db_query " - SELECT - type, - COUNT(*) AS count, - SUM(CASE WHEN processed THEN 1 ELSE 0 END) AS processed, - SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors - FROM stripe_webhook_events - WHERE received_at > NOW() - INTERVAL '24 hours' - GROUP BY type - ORDER BY count DESC - LIMIT 10; - " - - printf "\nLast 24 hours summary:\n" - plugin_db_query " - SELECT - COUNT(*) AS total_events, - SUM(CASE WHEN processed THEN 1 ELSE 0 END) AS processed, - SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS with_errors - FROM stripe_webhook_events - WHERE received_at > NOW() - INTERVAL '24 hours'; - " -} - -list_events() { - local limit="${1:-20}" - local type_filter="${2:-}" - - printf "\n=== Recent Webhook Events ===\n\n" - - local where_clause="" - if [[ -n "$type_filter" ]]; then - where_clause="AND type LIKE '%${type_filter}%'" - fi - - plugin_db_query " - SELECT - id, - type, - object_id, - CASE WHEN processed THEN 'Yes' ELSE 'No' END AS processed, - CASE WHEN error IS NOT NULL THEN 'Error' ELSE '-' END AS status, - to_char(received_at, 'YYYY-MM-DD HH24:MI:SS') AS received - FROM stripe_webhook_events - WHERE 1=1 $where_clause - ORDER BY received_at DESC - LIMIT $limit; - " -} - -get_event() { - local event_id="$1" - - printf "\n=== Webhook Event: %s ===\n\n" "$event_id" - - plugin_db_query " - SELECT - id, - type, - api_version, - object_type, - object_id, - processed, - processed_at, - error, - retry_count, - livemode, - created_at, - received_at - FROM stripe_webhook_events - WHERE id = '$event_id'; - " - - printf "\nEvent Data (truncated):\n" - plugin_db_query " - SELECT LEFT(data::text, 500) AS data_preview - FROM stripe_webhook_events - WHERE id = '$event_id'; - " -} - -show_errors() { - local limit="${1:-20}" - - printf "\n=== Failed Webhook Events ===\n\n" - - plugin_db_query " - SELECT - id, - type, - error, - retry_count, - to_char(received_at, 'YYYY-MM-DD HH24:MI') AS received - FROM stripe_webhook_events - WHERE error IS NOT NULL - ORDER BY received_at DESC - LIMIT $limit; - " -} - -retry_event() { - local event_id="$1" - - plugin_info "Retrying event: $event_id" - - # Get event data - local event_data - event_data=$(plugin_db_query "SELECT data::text FROM stripe_webhook_events WHERE id = '$event_id';") - - if [[ -z "$event_data" ]]; then - plugin_error "Event not found: $event_id" - return 1 - fi - - # Reconstruct payload - local event_type - event_type=$(plugin_db_query "SELECT type FROM stripe_webhook_events WHERE id = '$event_id';") - event_type=$(echo "$event_type" | xargs) - - local payload - payload=$(printf '{"id":"%s","type":"%s",%s}' "$event_id" "$event_type" "${event_data:1}") - - # Process through handler - if bash "${PLUGIN_DIR}/webhooks/handler.sh" "$payload"; then - plugin_success "Event reprocessed successfully" - else - plugin_error "Event reprocessing failed" - return 1 - fi -} - -retry_all_failed() { - local limit="${1:-10}" - - plugin_info "Retrying up to $limit failed events..." - - local failed_ids - failed_ids=$(plugin_db_query " - SELECT id FROM stripe_webhook_events - WHERE error IS NOT NULL - AND retry_count < 3 - ORDER BY received_at DESC - LIMIT $limit; - ") - - local count=0 - local success=0 - - while IFS= read -r event_id; do - event_id=$(echo "$event_id" | xargs) - [[ -z "$event_id" ]] && continue - - ((count++)) - if retry_event "$event_id" 2>/dev/null; then - ((success++)) - fi - done <<< "$failed_ids" - - printf "\n" - plugin_info "Retried $count events, $success succeeded" -} - -# ============================================================================= -# Main -# ============================================================================= - -show_help() { - printf "Usage: nself plugin stripe webhook [args]\n\n" - printf "Commands:\n" - printf " status Show webhook configuration and stats\n" - printf " events [limit] [type] List recent webhook events\n" - printf " get Get event details\n" - printf " errors [limit] Show failed events\n" - printf " retry Retry a failed event\n" - printf " retry-all [limit] Retry all failed events (default: 10)\n\n" - printf "Examples:\n" - printf " nself plugin stripe webhook status\n" - printf " nself plugin stripe webhook events 50 customer\n" - printf " nself plugin stripe webhook retry evt_abc123\n" -} - -main() { - local command="${1:-status}" - shift || true - - case "$command" in - status) - show_status - ;; - events|list) - list_events "${1:-20}" "${2:-}" - ;; - get) - if [[ -z "${1:-}" ]]; then - plugin_error "Event ID required" - return 1 - fi - get_event "$1" - ;; - errors|failed) - show_errors "${1:-20}" - ;; - retry) - if [[ -z "${1:-}" ]]; then - plugin_error "Event ID required" - return 1 - fi - retry_event "$1" - ;; - retry-all) - retry_all_failed "${1:-10}" - ;; - -h|--help|help) - show_help - ;; - *) - plugin_error "Unknown command: $command" - show_help - return 1 - ;; - esac -} - -main "$@" diff --git a/free/stripe/install.sh b/free/stripe/install.sh deleted file mode 100644 index 15d5792ca..000000000 --- a/free/stripe/install.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe Plugin Installer -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" -source "${SHARED_DIR}/schema-sync.sh" - -# ============================================================================= -# Installation -# ============================================================================= - -install_stripe_plugin() { - plugin_info "Installing Stripe plugin..." - - # Check required environment variables - if [[ -z "${STRIPE_API_KEY:-}" && -z "${STRIPE_API_KEYS:-}" ]]; then - plugin_warn "Stripe API key not set. Configure STRIPE_API_KEY or STRIPE_API_KEYS before using the plugin." - plugin_info "Add to your .env: STRIPE_API_KEY=sk_live_... (or STRIPE_API_KEYS=sk_live_legacy,sk_live_rebrand)" - fi - - # Apply database schema - plugin_info "Applying database schema..." - - # Ensure migrations table exists - schema_ensure_migrations_table - - # Apply main schema - if [[ -f "${PLUGIN_DIR}/schema/tables.sql" ]]; then - plugin_db_exec_file "${PLUGIN_DIR}/schema/tables.sql" - fi - - # Apply migrations - if [[ -d "${PLUGIN_DIR}/schema/migrations" ]]; then - for migration in "${PLUGIN_DIR}/schema/migrations"/*.sql; do - [[ ! -f "$migration" ]] && continue - - local migration_name - migration_name=$(basename "$migration" .sql) - - if ! schema_migration_applied "stripe" "$migration_name"; then - plugin_info "Applying migration: $migration_name" - plugin_db_exec_file "$migration" - schema_record_migration "stripe" "$migration_name" - fi - done - fi - - # Create cache and log directories - mkdir -p "${HOME}/.nself/cache/plugins/stripe" - mkdir -p "${HOME}/.nself/logs/plugins/stripe" - - # Register webhook endpoint (if webhook secret is configured) - if [[ -n "${STRIPE_WEBHOOK_SECRET:-}" ]]; then - plugin_info "Webhook endpoint configured" - else - plugin_info "To enable webhooks, set STRIPE_WEBHOOK_SECRET in your .env" - fi - - plugin_success "Stripe plugin installed successfully!" - - printf "\n" - printf "Next steps:\n" - printf " 1. Add STRIPE_API_KEY (or STRIPE_API_KEYS) to your .env file\n" - printf " 2. Run 'nself plugin stripe sync' to import existing data\n" - printf " 3. Configure webhooks at https://dashboard.stripe.com/webhooks\n" - printf "\n" -} - -# Run installation -install_stripe_plugin diff --git a/free/stripe/uninstall.sh b/free/stripe/uninstall.sh deleted file mode 100644 index 007029781..000000000 --- a/free/stripe/uninstall.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe Plugin Uninstaller -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Uninstallation -# ============================================================================= - -uninstall_stripe_plugin() { - local keep_data="${1:-false}" - - plugin_info "Uninstalling Stripe plugin..." - - if [[ "$keep_data" != "true" ]]; then - printf "\n" - printf "\033[33mWarning: This will delete all Stripe data from the database.\033[0m\n" - printf "Tables to be removed:\n" - printf " - stripe_customers\n" - printf " - stripe_products\n" - printf " - stripe_prices\n" - printf " - stripe_subscriptions\n" - printf " - stripe_invoices\n" - printf " - stripe_payment_intents\n" - printf " - stripe_payment_methods\n" - printf " - stripe_webhook_events\n" - printf "\n" - - read -r -p "Are you sure you want to continue? [y/N] " response - response=$(echo "$response" | tr '[:upper:]' '[:lower:]') - - if [[ "$response" != "y" && "$response" != "yes" ]]; then - plugin_info "Uninstallation cancelled" - return 0 - fi - - # Drop tables - plugin_info "Removing database tables..." - - plugin_db_query "DROP TABLE IF EXISTS stripe_webhook_events CASCADE;" >/dev/null 2>&1 || true - plugin_db_query "DROP TABLE IF EXISTS stripe_payment_methods CASCADE;" >/dev/null 2>&1 || true - plugin_db_query "DROP TABLE IF EXISTS stripe_payment_intents CASCADE;" >/dev/null 2>&1 || true - plugin_db_query "DROP TABLE IF EXISTS stripe_invoices CASCADE;" >/dev/null 2>&1 || true - plugin_db_query "DROP TABLE IF EXISTS stripe_subscriptions CASCADE;" >/dev/null 2>&1 || true - plugin_db_query "DROP TABLE IF EXISTS stripe_prices CASCADE;" >/dev/null 2>&1 || true - plugin_db_query "DROP TABLE IF EXISTS stripe_products CASCADE;" >/dev/null 2>&1 || true - plugin_db_query "DROP TABLE IF EXISTS stripe_customers CASCADE;" >/dev/null 2>&1 || true - - # Remove migration records - plugin_db_query "DELETE FROM _nself_plugin_migrations WHERE plugin_name = 'stripe';" >/dev/null 2>&1 || true - else - plugin_info "Keeping data in database (--keep-data flag set)" - fi - - # Clean up cache and logs - rm -rf "${HOME}/.nself/cache/plugins/stripe" - rm -rf "${HOME}/.nself/logs/plugins/stripe" - rm -rf "${HOME}/.nself/queue/webhooks/stripe" - - plugin_success "Stripe plugin uninstalled successfully!" -} - -# Parse arguments -KEEP_DATA="false" -while [[ $# -gt 0 ]]; do - case "$1" in - --keep-data) - KEEP_DATA="true" - shift - ;; - *) - shift - ;; - esac -done - -# Run uninstallation -uninstall_stripe_plugin "$KEEP_DATA" diff --git a/free/stripe/webhooks/events/customer_created.sh b/free/stripe/webhooks/events/customer_created.sh deleted file mode 100644 index e63c39bf9..000000000 --- a/free/stripe/webhooks/events/customer_created.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe customer.created Event Handler -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -handle_customer_created() { - local payload="$1" - - # Extract customer data from webhook payload - local customer_data - customer_data=$(printf '%s' "$payload" | grep -o '"object":{[^}]*}' | head -1) - - local customer_id email name phone description currency created - - customer_id=$(plugin_json_get "$customer_data" "id") - email=$(plugin_json_get "$customer_data" "email") - name=$(plugin_json_get "$customer_data" "name") - phone=$(plugin_json_get "$customer_data" "phone") - description=$(plugin_json_get "$customer_data" "description") - currency=$(plugin_json_get "$customer_data" "currency") - created=$(printf '%s' "$customer_data" | grep -o '"created":[0-9]*' | sed 's/"created"://') - - plugin_info "Creating customer: $customer_id ($email)" - - # Escape values for SQL - email=$(printf '%s' "$email" | sed "s/'/''/g") - name=$(printf '%s' "$name" | sed "s/'/''/g") - description=$(printf '%s' "$description" | sed "s/'/''/g") - - # Insert customer - plugin_db_query " - INSERT INTO stripe_customers ( - id, email, name, phone, description, currency, created_at, synced_at - ) VALUES ( - '$customer_id', - $([ -n "$email" ] && echo "'$email'" || echo "NULL"), - $([ -n "$name" ] && echo "'$name'" || echo "NULL"), - $([ -n "$phone" ] && echo "'$phone'" || echo "NULL"), - $([ -n "$description" ] && echo "'$description'" || echo "NULL"), - $([ -n "$currency" ] && echo "'$currency'" || echo "NULL"), - to_timestamp($created), - NOW() - ) - ON CONFLICT (id) DO UPDATE SET - email = EXCLUDED.email, - name = EXCLUDED.name, - phone = EXCLUDED.phone, - description = EXCLUDED.description, - currency = EXCLUDED.currency, - synced_at = NOW(); - " >/dev/null - - plugin_success "Customer created: $customer_id" -} - -handle_customer_created "$@" diff --git a/free/stripe/webhooks/events/customer_updated.sh b/free/stripe/webhooks/events/customer_updated.sh deleted file mode 100644 index 2561f8fba..000000000 --- a/free/stripe/webhooks/events/customer_updated.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe customer.updated Event Handler -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -handle_customer_updated() { - local payload="$1" - - local customer_data - customer_data=$(printf '%s' "$payload" | grep -o '"object":{[^}]*}' | head -1) - - local customer_id email name phone description currency balance delinquent - - customer_id=$(plugin_json_get "$customer_data" "id") - email=$(plugin_json_get "$customer_data" "email") - name=$(plugin_json_get "$customer_data" "name") - phone=$(plugin_json_get "$customer_data" "phone") - description=$(plugin_json_get "$customer_data" "description") - currency=$(plugin_json_get "$customer_data" "currency") - balance=$(printf '%s' "$customer_data" | grep -o '"balance":[0-9-]*' | sed 's/"balance"://') - delinquent=$(printf '%s' "$customer_data" | grep -o '"delinquent":[a-z]*' | sed 's/"delinquent"://') - - plugin_info "Updating customer: $customer_id ($email)" - - # Escape values - email=$(printf '%s' "$email" | sed "s/'/''/g") - name=$(printf '%s' "$name" | sed "s/'/''/g") - description=$(printf '%s' "$description" | sed "s/'/''/g") - - plugin_db_query " - UPDATE stripe_customers SET - email = $([ -n "$email" ] && echo "'$email'" || echo "email"), - name = $([ -n "$name" ] && echo "'$name'" || echo "name"), - phone = $([ -n "$phone" ] && echo "'$phone'" || echo "phone"), - description = $([ -n "$description" ] && echo "'$description'" || echo "description"), - currency = $([ -n "$currency" ] && echo "'$currency'" || echo "currency"), - balance = $([ -n "$balance" ] && echo "$balance" || echo "balance"), - delinquent = $([ -n "$delinquent" ] && echo "$delinquent" || echo "delinquent"), - updated_at = NOW(), - synced_at = NOW() - WHERE id = '$customer_id'; - " >/dev/null - - plugin_success "Customer updated: $customer_id" -} - -handle_customer_updated "$@" diff --git a/free/stripe/webhooks/events/default.sh b/free/stripe/webhooks/events/default.sh deleted file mode 100644 index b47e58a7b..000000000 --- a/free/stripe/webhooks/events/default.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Default Stripe Event Handler -# Handles events that don't have specific handlers -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Default Handler -# ============================================================================= - -handle_default_event() { - local event_type="$1" - local payload="$2" - - plugin_debug "Default handler for: $event_type" - - # Extract object type from event type (e.g., customer.created -> customer) - local object_type - object_type=$(echo "$event_type" | cut -d'.' -f1) - - # Log unhandled event - plugin_info "No specific handler for $event_type - event recorded for audit" - - return 0 -} - -# Run -handle_default_event "$@" diff --git a/free/stripe/webhooks/events/invoice_paid.sh b/free/stripe/webhooks/events/invoice_paid.sh deleted file mode 100644 index dc7e2f7f7..000000000 --- a/free/stripe/webhooks/events/invoice_paid.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe invoice.paid Event Handler -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -handle_invoice_paid() { - local payload="$1" - - local invoice_data - invoice_data=$(printf '%s' "$payload" | grep -o '"object":{[^}]*}' | head -1) - - local invoice_id customer_id subscription_id amount_paid currency status - - invoice_id=$(plugin_json_get "$invoice_data" "id") - customer_id=$(plugin_json_get "$invoice_data" "customer") - subscription_id=$(plugin_json_get "$invoice_data" "subscription") - amount_paid=$(printf '%s' "$invoice_data" | grep -o '"amount_paid":[0-9]*' | sed 's/"amount_paid"://') - currency=$(plugin_json_get "$invoice_data" "currency") - status=$(plugin_json_get "$invoice_data" "status") - - plugin_info "Invoice paid: $invoice_id (amount: $amount_paid $currency)" - - # Update or insert invoice - plugin_db_query " - INSERT INTO stripe_invoices ( - id, customer_id, subscription_id, amount_paid, currency, status, paid, synced_at - ) VALUES ( - '$invoice_id', - '$customer_id', - $([ -n "$subscription_id" ] && echo "'$subscription_id'" || echo "NULL"), - $amount_paid, - '$currency', - '$status', - TRUE, - NOW() - ) - ON CONFLICT (id) DO UPDATE SET - amount_paid = EXCLUDED.amount_paid, - status = EXCLUDED.status, - paid = TRUE, - synced_at = NOW(); - " >/dev/null - - plugin_success "Invoice marked as paid: $invoice_id" -} - -handle_invoice_paid "$@" diff --git a/free/stripe/webhooks/events/payment_intent_succeeded.sh b/free/stripe/webhooks/events/payment_intent_succeeded.sh deleted file mode 100644 index 7cae64c6b..000000000 --- a/free/stripe/webhooks/events/payment_intent_succeeded.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe payment_intent.succeeded Event Handler -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -handle_payment_intent_succeeded() { - local payload="$1" - - local pi_data - pi_data=$(printf '%s' "$payload" | grep -o '"object":{[^}]*}' | head -1) - - local pi_id customer_id amount amount_received currency status payment_method_id created - - pi_id=$(plugin_json_get "$pi_data" "id") - customer_id=$(plugin_json_get "$pi_data" "customer") - amount=$(printf '%s' "$pi_data" | grep -o '"amount":[0-9]*' | head -1 | sed 's/"amount"://') - amount_received=$(printf '%s' "$pi_data" | grep -o '"amount_received":[0-9]*' | sed 's/"amount_received"://') - currency=$(plugin_json_get "$pi_data" "currency") - status=$(plugin_json_get "$pi_data" "status") - payment_method_id=$(plugin_json_get "$pi_data" "payment_method") - created=$(printf '%s' "$pi_data" | grep -o '"created":[0-9]*' | sed 's/"created"://') - - plugin_info "Payment succeeded: $pi_id (amount: $amount_received $currency)" - - plugin_db_query " - INSERT INTO stripe_payment_intents ( - id, customer_id, amount, amount_received, currency, status, - payment_method_id, created_at, synced_at - ) VALUES ( - '$pi_id', - $([ -n "$customer_id" ] && echo "'$customer_id'" || echo "NULL"), - ${amount:-0}, - ${amount_received:-0}, - '$currency', - '$status', - $([ -n "$payment_method_id" ] && echo "'$payment_method_id'" || echo "NULL"), - to_timestamp($created), - NOW() - ) - ON CONFLICT (id) DO UPDATE SET - amount_received = EXCLUDED.amount_received, - status = EXCLUDED.status, - synced_at = NOW(); - " >/dev/null - - plugin_success "Payment intent recorded: $pi_id" -} - -handle_payment_intent_succeeded "$@" diff --git a/free/stripe/webhooks/events/subscription_created.sh b/free/stripe/webhooks/events/subscription_created.sh deleted file mode 100644 index 79ca915dc..000000000 --- a/free/stripe/webhooks/events/subscription_created.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe subscription.created Event Handler -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -source "${SHARED_DIR}/plugin-utils.sh" - -handle_subscription_created() { - local payload="$1" - - local sub_data - sub_data=$(printf '%s' "$payload" | grep -o '"object":{[^}]*}' | head -1) - - local sub_id customer_id status current_period_start current_period_end cancel_at_period_end created - - sub_id=$(plugin_json_get "$sub_data" "id") - customer_id=$(plugin_json_get "$sub_data" "customer") - status=$(plugin_json_get "$sub_data" "status") - current_period_start=$(printf '%s' "$sub_data" | grep -o '"current_period_start":[0-9]*' | sed 's/"current_period_start"://') - current_period_end=$(printf '%s' "$sub_data" | grep -o '"current_period_end":[0-9]*' | sed 's/"current_period_end"://') - cancel_at_period_end=$(printf '%s' "$sub_data" | grep -o '"cancel_at_period_end":[a-z]*' | sed 's/"cancel_at_period_end"://') - created=$(printf '%s' "$sub_data" | grep -o '"created":[0-9]*' | sed 's/"created"://') - - # Extract items - local items - items=$(printf '%s' "$payload" | grep -o '"items":{[^}]*}' | head -1 || echo '[]') - - plugin_info "Creating subscription: $sub_id (customer: $customer_id, status: $status)" - - # Escape items JSON - items=$(printf '%s' "$items" | sed "s/'/''/g") - - plugin_db_query " - INSERT INTO stripe_subscriptions ( - id, customer_id, status, current_period_start, current_period_end, - cancel_at_period_end, items, created_at, synced_at - ) VALUES ( - '$sub_id', - '$customer_id', - '$status', - to_timestamp($current_period_start), - to_timestamp($current_period_end), - ${cancel_at_period_end:-false}, - '$items'::jsonb, - to_timestamp($created), - NOW() - ) - ON CONFLICT (id) DO UPDATE SET - status = EXCLUDED.status, - current_period_start = EXCLUDED.current_period_start, - current_period_end = EXCLUDED.current_period_end, - cancel_at_period_end = EXCLUDED.cancel_at_period_end, - items = EXCLUDED.items, - synced_at = NOW(); - " >/dev/null - - plugin_success "Subscription created: $sub_id" -} - -handle_subscription_created "$@" diff --git a/free/stripe/webhooks/handler.sh b/free/stripe/webhooks/handler.sh deleted file mode 100644 index c18729593..000000000 --- a/free/stripe/webhooks/handler.sh +++ /dev/null @@ -1,221 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Stripe Webhook Handler -# Receives and processes Stripe webhook events -# ============================================================================= - -set -euo pipefail - -PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHARED_DIR="$(dirname "$PLUGIN_DIR")/../shared" - -# Source utilities -source "${SHARED_DIR}/plugin-utils.sh" - -# ============================================================================= -# Configuration -# ============================================================================= - -STRIPE_API_KEY="${STRIPE_API_KEY:-}" -STRIPE_WEBHOOK_SECRET="${STRIPE_WEBHOOK_SECRET:-}" - -# ============================================================================= -# Signature Verification -# ============================================================================= - -verify_stripe_signature() { - local payload="$1" - local signature_header="$2" - local secret="$3" - - # Parse Stripe signature header - local timestamp signature - timestamp=$(echo "$signature_header" | sed -n 's/.*t=\([0-9]*\).*/\1/p') - signature=$(echo "$signature_header" | sed -n 's/.*v1=\([a-f0-9]*\).*/\1/p') - - if [[ -z "$timestamp" || -z "$signature" ]]; then - plugin_error "Invalid signature header format" - return 1 - fi - - # Check timestamp tolerance (5 minutes) - local current_time - current_time=$(date +%s) - local tolerance=300 - - if (( current_time - timestamp > tolerance )); then - plugin_error "Webhook timestamp too old" - return 1 - fi - - # Compute expected signature - local signed_payload="${timestamp}.${payload}" - local expected_signature - expected_signature=$(printf '%s' "$signed_payload" | openssl dgst -sha256 -hmac "$secret" | sed 's/^.* //') - - if [[ "$signature" != "$expected_signature" ]]; then - plugin_error "Signature verification failed" - return 1 - fi - - return 0 -} - -# ============================================================================= -# Event Recording -# ============================================================================= - -record_webhook_event() { - local event_id="$1" - local event_type="$2" - local api_version="$3" - local data="$4" - local object_type="$5" - local object_id="$6" - local created_at="$7" - local livemode="$8" - - # Escape data for SQL - local escaped_data - escaped_data=$(printf '%s' "$data" | sed "s/'/''/g") - - plugin_db_query " - INSERT INTO stripe_webhook_events ( - id, type, api_version, data, object_type, object_id, - created_at, livemode, received_at - ) VALUES ( - '$event_id', - '$event_type', - '$api_version', - '$escaped_data'::jsonb, - '$object_type', - '$object_id', - to_timestamp($created_at), - $livemode, - NOW() - ) - ON CONFLICT (id) DO UPDATE SET - received_at = NOW(), - retry_count = stripe_webhook_events.retry_count + 1; - " >/dev/null -} - -mark_event_processed() { - local event_id="$1" - local error="${2:-}" - - if [[ -n "$error" ]]; then - local escaped_error - escaped_error=$(printf '%s' "$error" | sed "s/'/''/g") - plugin_db_query " - UPDATE stripe_webhook_events - SET processed = FALSE, error = '$escaped_error' - WHERE id = '$event_id'; - " >/dev/null - else - plugin_db_query " - UPDATE stripe_webhook_events - SET processed = TRUE, processed_at = NOW(), error = NULL - WHERE id = '$event_id'; - " >/dev/null - fi -} - -# ============================================================================= -# Event Processing -# ============================================================================= - -process_webhook() { - local payload="$1" - local signature_header="${2:-}" - - plugin_debug "Processing Stripe webhook..." - - # Verify signature if secret is configured - if [[ -n "$STRIPE_WEBHOOK_SECRET" && -n "$signature_header" ]]; then - if ! verify_stripe_signature "$payload" "$signature_header" "$STRIPE_WEBHOOK_SECRET"; then - plugin_error "Webhook signature verification failed" - return 1 - fi - plugin_debug "Signature verified" - fi - - # Parse event - local event_id event_type api_version data object_type object_id created_at livemode - - event_id=$(plugin_json_get "$payload" "id") - event_type=$(plugin_json_get "$payload" "type") - api_version=$(plugin_json_get "$payload" "api_version") - created_at=$(printf '%s' "$payload" | grep -o '"created":[0-9]*' | head -1 | sed 's/"created"://') - livemode=$(printf '%s' "$payload" | grep -o '"livemode":[a-z]*' | head -1 | sed 's/"livemode"://') - - # Extract object info - object_type=$(printf '%s' "$payload" | grep -o '"object":"[^"]*"' | head -1 | sed 's/"object":"\([^"]*\)"/\1/') - object_id=$(printf '%s' "$payload" | grep -o '"data":{[^}]*"object":{[^}]*"id":"[^"]*"' | grep -o '"id":"[^"]*"' | head -1 | sed 's/"id":"\([^"]*\)"/\1/') - - # Get data object - data=$(printf '%s' "$payload" | grep -o '"data":{.*}' | head -1) - - plugin_info "Received event: $event_type ($event_id)" - - # Record event in database - record_webhook_event "$event_id" "$event_type" "$api_version" "$data" "$object_type" "$object_id" "$created_at" "$livemode" - - # Find and execute event handler - local event_handler_dir="${PLUGIN_DIR}/webhooks/events" - local event_file - event_file=$(echo "$event_type" | tr '.' '_') - - local handler_result=0 - - if [[ -f "${event_handler_dir}/${event_file}.sh" ]]; then - plugin_debug "Executing handler: ${event_file}.sh" - if bash "${event_handler_dir}/${event_file}.sh" "$payload"; then - plugin_success "Event processed: $event_type" - else - handler_result=$? - plugin_error "Handler failed for: $event_type" - fi - else - plugin_debug "No specific handler for $event_type, using default sync" - # Default: sync the object - bash "${event_handler_dir}/default.sh" "$event_type" "$payload" || handler_result=$? - fi - - # Mark event as processed - if [[ $handler_result -eq 0 ]]; then - mark_event_processed "$event_id" - else - mark_event_processed "$event_id" "Handler returned exit code $handler_result" - fi - - plugin_log_webhook "stripe" "$event_type" "$event_id" "$([[ $handler_result -eq 0 ]] && echo 'success' || echo 'failed')" - - return $handler_result -} - -# ============================================================================= -# Main -# ============================================================================= - -main() { - local payload="${1:-}" - local signature_header="${STRIPE_SIGNATURE:-}" - - if [[ -z "$payload" ]]; then - # Read from stdin if no argument - payload=$(cat) - fi - - if [[ -z "$payload" ]]; then - plugin_error "No payload received" - return 1 - fi - - process_webhook "$payload" "$signature_header" -} - -# Run if executed directly -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi diff --git a/shared/generate-plugin.sh b/shared/generate-plugin.sh deleted file mode 100755 index 4074ac483..000000000 --- a/shared/generate-plugin.sh +++ /dev/null @@ -1,2069 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# nself Plugin Template Generator -# Generates a complete plugin scaffold under free// -# -# Usage: -# ./generate-plugin.sh [--description "..."] [--author "..."] -# -# Example: -# ./generate-plugin.sh my-service integrations --description "My service integration" --author "nself" -# -# Bash 3.2 compatible (macOS default shell): -# - No echo -e (use printf) -# - No ${var,,} or ${var^^} (use tr) -# - No {1..N} in for loops (use seq) -# - No declare -A associative arrays -# ============================================================================= - -set -e - -# ============================================================================= -# Color Codes (printf-safe) -# ============================================================================= - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -BOLD='\033[1m' -RESET='\033[0m' - -# ============================================================================= -# Logging Helpers -# ============================================================================= - -log_info() { - printf "${BLUE} →${RESET} %s\n" "$*" -} - -log_success() { - printf "${GREEN} ✓${RESET} %s\n" "$*" -} - -log_warn() { - printf "${YELLOW} ⚠${RESET} %s\n" "$*" -} - -log_error() { - printf "${RED} ✗${RESET} %s\n" "$*" >&2 -} - -log_header() { - printf "\n${BOLD}${CYAN}%s${RESET}\n" "$*" - printf "${CYAN}%s${RESET}\n" "$(printf '=%.0s' $(seq 1 ${#1}))" -} - -log_step() { - printf "${BOLD} [%s]${RESET} %s\n" "$1" "$2" -} - -# ============================================================================= -# Utility Functions — Bash 3.2 Compatible -# ============================================================================= - -# Convert kebab-case to PascalCase: "my-plugin" → "MyPlugin" -to_pascal_case() { - local input="$1" - local result="" - local word="" - - # Replace hyphens with spaces, then capitalize each word - local spaced - spaced=$(printf '%s' "$input" | tr '-' ' ') - - for word in $spaced; do - # Capitalize first letter using tr (Bash 3.2 compatible) - local first_char - first_char=$(printf '%s' "$word" | cut -c1 | tr '[:lower:]' '[:upper:]') - local rest - rest=$(printf '%s' "$word" | cut -c2-) - result="${result}${first_char}${rest}" - done - - printf '%s' "$result" -} - -# Convert kebab-case to SCREAMING_SNAKE_CASE: "my-plugin" → "MY_PLUGIN" -to_upper_snake() { - printf '%s' "$1" | tr '-' '_' | tr '[:lower:]' '[:upper:]' -} - -# Convert kebab-case to lowercase with underscores: "my-plugin" → "my_plugin" -to_snake_case() { - printf '%s' "$1" | tr '-' '_' | tr '[:upper:]' '[:lower:]' -} - -# Strip hyphens entirely: "my-plugin" → "myplugin" -strip_hyphens() { - printf '%s' "$1" | tr -d '-' -} - -# ============================================================================= -# Validation -# ============================================================================= - -VALID_CATEGORIES="authentication automation commerce communication content data development infrastructure integrations media streaming sports compliance" - -validate_plugin_name() { - local name="$1" - - # Must be lowercase letters, numbers, and hyphens only - case "$name" in - *[A-Z]*) - log_error "Plugin name must be lowercase. Got: $name" - return 1 - ;; - *_*) - log_error "Plugin name must use hyphens, not underscores. Got: $name" - return 1 - ;; - *[^a-z0-9-]*) - log_error "Plugin name may only contain lowercase letters, digits, and hyphens. Got: $name" - return 1 - ;; - -* | *-) - log_error "Plugin name cannot start or end with a hyphen. Got: $name" - return 1 - ;; - esac - - if [ ${#name} -lt 2 ]; then - log_error "Plugin name must be at least 2 characters long." - return 1 - fi - - if [ ${#name} -gt 64 ]; then - log_error "Plugin name must be 64 characters or fewer." - return 1 - fi - - return 0 -} - -validate_category() { - local category="$1" - local cat - - for cat in $VALID_CATEGORIES; do - if [ "$cat" = "$category" ]; then - return 0 - fi - done - - log_error "Invalid category: '$category'" - log_error "Must be one of: $VALID_CATEGORIES" - return 1 -} - -# ============================================================================= -# Argument Parsing -# ============================================================================= - -usage() { - printf "\n${BOLD}Usage:${RESET}\n" - printf " %s [options]\n\n" "$(basename "$0")" - printf "${BOLD}Arguments:${RESET}\n" - printf " plugin-name Lowercase, hyphens only (e.g. my-service)\n" - printf " category One of: %s\n\n" "$VALID_CATEGORIES" - printf "${BOLD}Options:${RESET}\n" - printf " --description \"...\" Plugin description (default: integration)\n" - printf " --author \"...\" Plugin author (default: nself)\n" - printf " --help Show this help\n\n" - printf "${BOLD}Example:${RESET}\n" - printf " %s my-service integrations --description \"My Service integration for nself\" --author \"Your Name\"\n\n" "$(basename "$0")" -} - -PLUGIN_NAME="" -CATEGORY="" -DESCRIPTION="" -AUTHOR="nself" - -parse_args() { - if [ $# -lt 2 ]; then - log_error "Missing required arguments." - usage - exit 1 - fi - - PLUGIN_NAME="$1" - CATEGORY="$2" - shift 2 - - while [ $# -gt 0 ]; do - case "$1" in - --description) - if [ -z "${2:-}" ]; then - log_error "--description requires a value" - exit 1 - fi - DESCRIPTION="$2" - shift 2 - ;; - --author) - if [ -z "${2:-}" ]; then - log_error "--author requires a value" - exit 1 - fi - AUTHOR="$2" - shift 2 - ;; - --help | -h) - usage - exit 0 - ;; - *) - log_error "Unknown argument: $1" - usage - exit 1 - ;; - esac - done - - # Set default description if not provided - if [ -z "$DESCRIPTION" ]; then - DESCRIPTION="${PLUGIN_NAME} integration for nself" - fi -} - -# ============================================================================= -# Derived Name Variants (computed after arg parsing) -# ============================================================================= - -compute_names() { - PASCAL_NAME=$(to_pascal_case "$PLUGIN_NAME") # MyPlugin - SNAKE_NAME=$(to_snake_case "$PLUGIN_NAME") # my_plugin - UPPER_NAME=$(to_upper_snake "$PLUGIN_NAME") # MY_PLUGIN - TABLE_PREFIX="np_${SNAKE_NAME}_" # np_my_plugin_ - # Strip hyphens for internal short abbreviation used in log namespaces - SHORT_NAME=$(strip_hyphens "$PLUGIN_NAME") # myplugin -} - -# ============================================================================= -# Target Directory -# ============================================================================= - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PLUGINS_ROOT="$(dirname "$SCRIPT_DIR")" # nself-plugins root (shared/../) -FREE_DIR="${PLUGINS_ROOT}/free" -TARGET_DIR="" - -setup_target_dir() { - TARGET_DIR="${FREE_DIR}/${PLUGIN_NAME}" - - if [ -d "$TARGET_DIR" ]; then - log_error "Plugin directory already exists: $TARGET_DIR" - log_error "Delete it first or choose a different plugin name." - exit 1 - fi - - if [ ! -d "$FREE_DIR" ]; then - log_error "Expected free plugins directory not found: $FREE_DIR" - log_error "Run this script from inside the nself-plugins/shared/ directory." - exit 1 - fi -} - -# ============================================================================= -# File Generation Helpers -# ============================================================================= - -make_dir() { - mkdir -p "$1" - log_success "Created directory: ${1#"$PLUGINS_ROOT/"}" -} - -write_file() { - local path="$1" - # Content is passed via heredoc from caller via stdin - cat > "$path" - log_success "Created: ${path#"$PLUGINS_ROOT/"}" -} - -# ============================================================================= -# Template: README.md -# ============================================================================= - -generate_readme() { - write_file "${TARGET_DIR}/README.md" </dev/null || true - chmod +x "\$webhook_runtime_dir"/*.sh 2>/dev/null || true - fi - - return 0 -} - -# ============================================================================ -# Main Installation -# ============================================================================ - -main() { - plugin_log "info" "Installing ${PASCAL_NAME} plugin..." - - if ! preflight_checks; then - plugin_log "error" "Pre-flight checks failed" - return 1 - fi - - if ! setup_database; then - plugin_log "error" "Database setup failed" - return 1 - fi - - if ! setup_webhooks; then - plugin_log "error" "Webhook setup failed" - return 1 - fi - - plugin_mark_installed "\$PLUGIN_NAME" - - plugin_log "success" "${PASCAL_NAME} plugin installed successfully" - - printf "\n" - printf "Usage:\n" - printf " nself plugin ${PLUGIN_NAME} sync - Sync data\n" - printf " nself plugin ${PLUGIN_NAME} status - Show status\n" - printf " nself plugin ${PLUGIN_NAME} webhook - Manage webhooks\n" - printf "\n" - - return 0 -} - -if [[ "\${BASH_SOURCE[0]}" == "\${0}" ]]; then - main "\$@" -fi -INSTALLSH - chmod +x "${TARGET_DIR}/install.sh" -} - -# ============================================================================= -# Template: uninstall.sh -# ============================================================================= - -generate_uninstall_sh() { - write_file "${TARGET_DIR}/uninstall.sh" </dev/null || true - plugin_db_query "DROP TABLE IF EXISTS ${TABLE_PREFIX}records CASCADE" 2>/dev/null || true - - plugin_log "success" "Database tables removed" - return 0 -} - -remove_webhooks() { - plugin_log "info" "Removing webhook handlers..." - - local webhook_runtime_dir="\${HOME}/.nself/plugins/\${PLUGIN_NAME}/webhooks" - - if [[ -d "\$webhook_runtime_dir" ]]; then - rm -rf "\$webhook_runtime_dir" - plugin_log "success" "Webhook handlers removed" - fi - - return 0 -} - -remove_cache() { - plugin_log "info" "Clearing plugin cache..." - - local cache_dir="\${HOME}/.nself/cache/plugins/\${PLUGIN_NAME}" - - if [[ -d "\$cache_dir" ]]; then - rm -rf "\$cache_dir" - plugin_log "success" "Cache cleared" - fi - - return 0 -} - -main() { - local keep_data=false - - while [[ \$# -gt 0 ]]; do - case "\$1" in - --keep-data) - keep_data=true - shift - ;; - *) - shift - ;; - esac - done - - plugin_log "info" "Uninstalling ${PASCAL_NAME} plugin..." - - remove_database "\$keep_data" - remove_webhooks - remove_cache - - plugin_mark_uninstalled "\$PLUGIN_NAME" - - plugin_log "success" "${PASCAL_NAME} plugin uninstalled" - - if [[ "\$keep_data" == "true" ]]; then - printf "\n" - printf "Note: Database tables were preserved. To remove them later:\n" - printf " nself db query \"DROP TABLE IF EXISTS ${TABLE_PREFIX}* CASCADE\"\n" - fi - - return 0 -} - -if [[ "\${BASH_SOURCE[0]}" == "\${0}" ]]; then - main "\$@" -fi -UNINSTALLSH - chmod +x "${TARGET_DIR}/uninstall.sh" -} - -# ============================================================================= -# Template: schema/.sql -# ============================================================================= - -generate_schema_sql() { - write_file "${TARGET_DIR}/schema/${PLUGIN_NAME}.sql" <=18.0.0" - }, - "license": "MIT" -} -PKGJSON -} - -# ============================================================================= -# Template: ts/tsconfig.json -# ============================================================================= - -generate_ts_tsconfig() { - write_file "${TARGET_DIR}/ts/tsconfig.json" <; - timestamp?: string; -} - -// ============================================================================= -// Database Record Types -// source_account_id is REQUIRED on every table for multi-app isolation -// ============================================================================= - -export interface ${PASCAL_NAME}Record { - id: string; // UUID primary key - source_account_id: string; // Multi-app isolation — NEVER omit - external_id: string | null; // ID from the external ${PASCAL_NAME} service - name: string | null; - description: string | null; - status: string | null; - data: Record; // Raw API data (JSONB) - created_at: Date | null; - updated_at: Date | null; - synced_at: Date; -} - -export interface ${PASCAL_NAME}WebhookEventRecord { - id: string; // UUID primary key - source_account_id: string; // Multi-app isolation — NEVER omit - event_type: string; - action: string | null; - external_id: string | null; // Resource ID from payload - data: Record; // Full webhook payload (JSONB) - processed: boolean; - processed_at: Date | null; - error: string | null; - received_at: Date; -} - -// ============================================================================= -// Sync Types -// ============================================================================= - -export interface SyncStats { - records: number; - webhookEvents?: number; - lastSyncedAt?: Date | null; -} - -export interface SyncOptions { - incremental?: boolean; - since?: Date; - limit?: number; -} - -export interface SyncResult { - success: boolean; - stats: SyncStats; - errors: string[]; - duration: number; -} -TYPES -} - -# ============================================================================= -# Template: ts/src/config.ts -# ============================================================================= - -generate_ts_config() { - write_file "${TARGET_DIR}/ts/src/config.ts" <): Config { - const security = loadSecurityConfig('${UPPER_NAME}'); - - const accounts = buildAccountConfigs( - process.env.${UPPER_NAME}_API_KEYS, - process.env.${UPPER_NAME}_ACCOUNT_LABELS, - process.env.${UPPER_NAME}_API_KEY, - 'primary' - ); - - const config: Config = { - // ${PASCAL_NAME} credentials - apiKey: process.env.${UPPER_NAME}_API_KEY ?? '', - webhookSecret: process.env.${UPPER_NAME}_WEBHOOK_SECRET ?? '', - - // Multi-app support - accounts, - - // Server - port: parseInt(process.env.${UPPER_NAME}_PLUGIN_PORT ?? process.env.PORT ?? '3000', 10), - host: process.env.${UPPER_NAME}_PLUGIN_HOST ?? process.env.HOST ?? '0.0.0.0', - - // Database - databaseHost: process.env.POSTGRES_HOST ?? 'postgres', - databasePort: parseInt(process.env.POSTGRES_PORT ?? '5432', 10), - databaseName: process.env.POSTGRES_DB ?? 'nself', - databaseUser: process.env.POSTGRES_USER ?? 'postgres', - databasePassword: process.env.POSTGRES_PASSWORD ?? '', - databaseSsl: process.env.POSTGRES_SSL === 'true', - - // Sync - syncInterval: parseInt(process.env.${UPPER_NAME}_SYNC_INTERVAL ?? '3600', 10), - logLevel: process.env.LOG_LEVEL ?? 'info', - - // Security - security, - - // Apply overrides - ...overrides, - }; - - // Validation - if (!config.apiKey) { - throw new Error('${UPPER_NAME}_API_KEY is required'); - } - - return config; -} -CONFIG -} - -# ============================================================================= -# Template: ts/src/client.ts -# ============================================================================= - -generate_ts_client() { - write_file "${TARGET_DIR}/ts/src/client.ts" <( - method: string, - path: string, - body?: Record - ): Promise { - const url = \`\${this.baseUrl}\${path}\`; - const headers: Record = { - 'Authorization': \`Bearer \${this.apiKey}\`, - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }; - - const response = await fetch(url, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error( - \`${PASCAL_NAME} API error \${response.status} \${response.statusText}: \${text}\` - ); - } - - return response.json() as Promise; - } - - private get(path: string): Promise { - return this.request('GET', path); - } - - // ========================================================================= - // Records - // Adapt these methods to match the actual ${PASCAL_NAME} API - // ========================================================================= - - /** - * List all records from ${PASCAL_NAME} - */ - async listRecords(options: { limit?: number; after?: string } = {}): Promise<${PASCAL_NAME}ApiRecord[]> { - logger.info('Fetching ${PLUGIN_NAME} records', options); - - // TODO_SCAFFOLD: Replace with actual ${PASCAL_NAME} API endpoint and pagination logic - const params = new URLSearchParams(); - if (options.limit) params.set('limit', String(options.limit)); - if (options.after) params.set('after', options.after); - - const query = params.toString() ? \`?\${params.toString()}\` : ''; - const data = await this.get<{ data: ${PASCAL_NAME}ApiRecord[] }>(\`/records\${query}\`); - - return data.data; - } - - /** - * Get a single record by ID - */ - async getRecord(id: string): Promise<${PASCAL_NAME}ApiRecord> { - logger.info('Fetching ${PLUGIN_NAME} record', { id }); - return this.get<${PASCAL_NAME}ApiRecord>(\`/records/\${id}\`); - } - - /** - * Test API connectivity and credentials - */ - async testConnection(): Promise { - try { - // TODO_SCAFFOLD: Replace with a lightweight ${PASCAL_NAME} API health/ping endpoint - await this.get('/'); - return true; - } catch { - return false; - } - } -} -CLIENT -} - -# ============================================================================= -# Template: ts/src/database.ts -# ============================================================================= - -generate_ts_database() { - write_file "${TARGET_DIR}/ts/src/database.ts" < { - await this.db.connect(); - } - - async disconnect(): Promise { - await this.db.disconnect(); - } - - // ========================================================================= - // Records — ${TABLE_PREFIX}records - // ========================================================================= - - /** - * Upsert a record (insert or update on conflict with external_id). - * Always sets source_account_id from this instance. - */ - async upsertRecord(record: Omit<${PASCAL_NAME}Record, 'id' | 'source_account_id' | 'synced_at'>): Promise { - logger.debug('Upserting record', { external_id: record.external_id }); - - await this.db.query( - \`INSERT INTO ${TABLE_PREFIX}records ( - source_account_id, - external_id, - name, - description, - status, - data, - created_at, - updated_at, - synced_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) - ON CONFLICT (source_account_id, external_id) - WHERE external_id IS NOT NULL - DO UPDATE SET - name = EXCLUDED.name, - description = EXCLUDED.description, - status = EXCLUDED.status, - data = EXCLUDED.data, - updated_at = EXCLUDED.updated_at, - synced_at = NOW()\`, - [ - this.sourceAccountId, - record.external_id, - record.name, - record.description, - record.status, - JSON.stringify(record.data), - record.created_at, - record.updated_at, - ] - ); - } - - /** - * Get a single record by external_id, scoped to this source_account_id. - */ - async getRecord(externalId: string): Promise<${PASCAL_NAME}Record | null> { - const result = await this.db.query<${PASCAL_NAME}Record>( - \`SELECT * FROM ${TABLE_PREFIX}records - WHERE source_account_id = $1 AND external_id = $2 - LIMIT 1\`, - [this.sourceAccountId, externalId] - ); - return result.rows[0] ?? null; - } - - /** - * List records for this source_account_id. - */ - async listRecords(limit = 100, offset = 0): Promise<${PASCAL_NAME}Record[]> { - const result = await this.db.query<${PASCAL_NAME}Record>( - \`SELECT * FROM ${TABLE_PREFIX}records - WHERE source_account_id = $1 - ORDER BY updated_at DESC NULLS LAST - LIMIT $2 OFFSET $3\`, - [this.sourceAccountId, limit, offset] - ); - return result.rows; - } - - /** - * Count all records for this source_account_id. - */ - async countRecords(): Promise { - const result = await this.db.query<{ count: string }>( - \`SELECT COUNT(*) AS count FROM ${TABLE_PREFIX}records - WHERE source_account_id = $1\`, - [this.sourceAccountId] - ); - return parseInt(result.rows[0]?.count ?? '0', 10); - } - - // ========================================================================= - // Webhook Events — ${TABLE_PREFIX}webhook_events - // ========================================================================= - - /** - * Insert a new webhook event. - */ - async insertWebhookEvent( - event: Omit<${PASCAL_NAME}WebhookEventRecord, 'id' | 'source_account_id' | 'received_at'> - ): Promise { - const result = await this.db.query<{ id: string }>( - \`INSERT INTO ${TABLE_PREFIX}webhook_events ( - source_account_id, - event_type, - action, - external_id, - data, - processed, - received_at - ) VALUES ($1, $2, $3, $4, $5, FALSE, NOW()) - RETURNING id\`, - [ - this.sourceAccountId, - event.event_type, - event.action, - event.external_id, - JSON.stringify(event.data), - ] - ); - return result.rows[0]!.id; - } - - /** - * Mark a webhook event as processed (or failed). - */ - async markWebhookProcessed(id: string, error?: string): Promise { - await this.db.query( - \`UPDATE ${TABLE_PREFIX}webhook_events - SET processed = TRUE, processed_at = NOW(), error = $2 - WHERE id = $1\`, - [id, error ?? null] - ); - } - - /** - * Fetch unprocessed webhook events for this source_account_id. - */ - async getUnprocessedWebhooks(limit = 50): Promise<${PASCAL_NAME}WebhookEventRecord[]> { - const result = await this.db.query<${PASCAL_NAME}WebhookEventRecord>( - \`SELECT * FROM ${TABLE_PREFIX}webhook_events - WHERE source_account_id = $1 AND processed = FALSE - ORDER BY received_at ASC - LIMIT $2\`, - [this.sourceAccountId, limit] - ); - return result.rows; - } -} -DATABASE -} - -# ============================================================================= -# Template: ts/src/sync.ts -# ============================================================================= - -generate_ts_sync() { - write_file "${TARGET_DIR}/ts/src/sync.ts" < { - if (this.syncing) { - throw new Error('Sync already in progress'); - } - - this.syncing = true; - const startTime = Date.now(); - const errors: string[] = []; - const stats: SyncStats = { - records: 0, - lastSyncedAt: null, - }; - - try { - logger.info('Starting ${PASCAL_NAME} sync', options); - - // ----------------------------------------------------------------------- - // Sync records - // ----------------------------------------------------------------------- - try { - const records = await this.client.listRecords({ - limit: options.limit, - }); - - for (const record of records) { - await this.db.upsertRecord({ - external_id: record.id ?? null, - name: (record.name as string) ?? null, - description: (record.description as string) ?? null, - status: (record.status as string) ?? null, - data: record as Record, - created_at: record.created_at ? new Date(record.created_at as string) : null, - updated_at: record.updated_at ? new Date(record.updated_at as string) : null, - }); - stats.records++; - } - - logger.info('Records synced', { count: stats.records }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.error('Failed to sync records', { error: message }); - errors.push(\`records: \${message}\`); - } - - stats.lastSyncedAt = new Date(); - - const duration = Date.now() - startTime; - logger.info('${PASCAL_NAME} sync complete', { stats, duration, errors: errors.length }); - - return { success: errors.length === 0, stats, errors, duration }; - } finally { - this.syncing = false; - } - } - - isSyncing(): boolean { - return this.syncing; - } -} -SYNC -} - -# ============================================================================= -# Template: ts/src/webhooks.ts -# ============================================================================= - -generate_ts_webhooks() { - write_file "${TARGET_DIR}/ts/src/webhooks.ts" < Promise; - -export class ${PASCAL_NAME}WebhookHandler { - private db: ${PASCAL_NAME}Database; - private handlers: Map; - - constructor(db: ${PASCAL_NAME}Database) { - this.db = db; - this.handlers = new Map(); - this.registerDefaultHandlers(); - } - - private registerDefaultHandlers(): void { - this.register('created', this.handleCreated.bind(this)); - this.register('updated', this.handleUpdated.bind(this)); - this.register('deleted', this.handleDeleted.bind(this)); - } - - register(event: string, handler: WebhookHandlerFn): void { - this.handlers.set(event, handler); - logger.debug('Registered handler', { event }); - } - - async handleEvent(payload: ${PASCAL_NAME}WebhookPayload): Promise { - const { event, action } = payload; - logger.info('Received webhook event', { event, action }); - - // Store the event before processing - const eventId = await this.db.insertWebhookEvent({ - event_type: event, - action: action ?? null, - external_id: (payload.id as string) ?? null, - data: payload.data, - processed: false, - processed_at: null, - error: null, - }); - - // Route to handler - const handlerKey = action ?? event; - const handler = this.handlers.get(handlerKey) ?? this.handlers.get(event); - - if (!handler) { - logger.warn('No handler for event', { event, action }); - await this.db.markWebhookProcessed(eventId, \`No handler for event: \${event}\`); - return; - } - - try { - await handler(payload); - await this.db.markWebhookProcessed(eventId); - logger.info('Webhook event processed', { event, action }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.error('Webhook handler failed', { event, action, error: message }); - await this.db.markWebhookProcessed(eventId, message); - throw err; - } - } - - // ========================================================================= - // Default Handlers — replace with actual ${PASCAL_NAME} logic - // ========================================================================= - - private async handleCreated(payload: ${PASCAL_NAME}WebhookPayload): Promise { - logger.info('Handling created event', { id: payload.id }); - // TODO_SCAFFOLD: Upsert the created resource into the database - // const record = payload.data as ${PASCAL_NAME}ApiRecord; - // await this.db.upsertRecord({ ... }); - } - - private async handleUpdated(payload: ${PASCAL_NAME}WebhookPayload): Promise { - logger.info('Handling updated event', { id: payload.id }); - // TODO_SCAFFOLD: Update the resource in the database - } - - private async handleDeleted(payload: ${PASCAL_NAME}WebhookPayload): Promise { - logger.info('Handling deleted event', { id: payload.id }); - // TODO_SCAFFOLD: Mark the resource as deleted or remove it - } -} -WEBHOOKS -} - -# ============================================================================= -# Template: ts/src/server.ts -# ============================================================================= - -generate_ts_server() { - write_file "${TARGET_DIR}/ts/src/server.ts" <) { - const fullConfig = loadConfig(config); - - // Initialize components - const client = new ${PASCAL_NAME}Client(fullConfig.apiKey); - const db = new ${PASCAL_NAME}Database(); - const syncService = new ${PASCAL_NAME}SyncService(client, db); - const webhookHandler = new ${PASCAL_NAME}WebhookHandler(db); - - // Connect to database - await db.connect(); - - // Create Fastify server - const app = Fastify({ - logger: false, - bodyLimit: 10 * 1024 * 1024, // 10 MB - }); - - // Register CORS - await app.register(cors, { - origin: true, - credentials: true, - }); - - // Rate limiting - const rateLimiter = new ApiRateLimiter( - fullConfig.security.rateLimitMax ?? 100, - fullConfig.security.rateLimitWindowMs ?? 60000 - ); - app.addHook('preHandler', createRateLimitHook(rateLimiter) as never); - - // Optional API key auth - if (fullConfig.security.apiKey) { - app.addHook('preHandler', createAuthHook(fullConfig.security.apiKey) as never); - logger.info('API key authentication enabled'); - } - - // ========================================================================= - // Health Endpoints - // ========================================================================= - - app.get('/health', async () => ({ status: 'ok', plugin: '${PLUGIN_NAME}' })); - - app.get('/status', async () => { - const recordCount = await db.countRecords(); - return { - plugin: '${PLUGIN_NAME}', - syncing: syncService.isSyncing(), - records: recordCount, - }; - }); - - // ========================================================================= - // Webhook Endpoint - // ========================================================================= - - app.post<{ Body: ${PASCAL_NAME}WebhookPayload }>( - '/webhooks/${PLUGIN_NAME}', - { - config: { skipAuth: true }, - }, - async (request, reply) => { - try { - // TODO_SCAFFOLD: Add signature verification here if ${PASCAL_NAME} supports it - // verifySignature(request.rawBody, request.headers['x-${PLUGIN_NAME}-signature'], fullConfig.webhookSecret); - - await webhookHandler.handleEvent(request.body); - return reply.code(200).send({ received: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.error('Webhook processing failed', { error: message }); - return reply.code(500).send({ error: message }); - } - } - ); - - // ========================================================================= - // Sync Endpoint - // ========================================================================= - - app.post('/sync', async (_request, reply) => { - if (syncService.isSyncing()) { - return reply.code(409).send({ error: 'Sync already in progress' }); - } - - // Run async — return immediately - syncService.sync().catch(err => { - logger.error('Background sync failed', { error: err instanceof Error ? err.message : String(err) }); - }); - - return reply.code(202).send({ message: 'Sync started' }); - }); - - // ========================================================================= - // Records API - // ========================================================================= - - app.get('/records', async (request) => { - const query = request.query as { limit?: string; offset?: string }; - const limit = parseInt(query.limit ?? '100', 10); - const offset = parseInt(query.offset ?? '0', 10); - const records = await db.listRecords(limit, offset); - return { data: records, limit, offset }; - }); - - return { app, db, syncService }; -} - -// ============================================================================= -// Entry Point (when run directly: node dist/server.js) -// ============================================================================= - -const config = loadConfig(); - -createServer(config).then(async ({ app }) => { - await app.listen({ port: config.port, host: config.host }); - logger.info(\`${PASCAL_NAME} plugin server listening\`, { port: config.port }); -}).catch(err => { - logger.error('Failed to start server', { error: err instanceof Error ? err.message : String(err) }); - process.exit(1); -}); -SERVER -} - -# ============================================================================= -# Template: ts/src/cli.ts -# ============================================================================= - -generate_ts_cli() { - write_file "${TARGET_DIR}/ts/src/cli.ts" <', 'Only sync changes since date (ISO 8601)') - .option('--limit ', 'Maximum records to sync', parseInt) - .action(async (options: { since?: string; limit?: number }) => { - try { - const config = loadConfig(); - const client = new ${PASCAL_NAME}Client(config.apiKey); - const db = new ${PASCAL_NAME}Database(); - await db.connect(); - - const syncService = new ${PASCAL_NAME}SyncService(client, db); - const result = await syncService.sync({ - since: options.since ? new Date(options.since) : undefined, - limit: options.limit, - }); - - console.log('\nSync Results:'); - console.log('============='); - console.log('Records: ', result.stats.records); - console.log('Duration: ', \`\${(result.duration / 1000).toFixed(1)}s\`); - - if (result.errors.length > 0) { - console.log('\nErrors:'); - result.errors.forEach(e => console.log(' -', e)); - process.exitCode = 1; - } - - await db.disconnect(); - } catch (err) { - logger.error('Sync failed', { error: err instanceof Error ? err.message : String(err) }); - process.exit(1); - } - }); - -// --------------------------------------------------------------------------- -// server command -// --------------------------------------------------------------------------- - -program - .command('server') - .description('Start the ${PASCAL_NAME} webhook server') - .action(async () => { - try { - const config = loadConfig(); - const { app } = await createServer(config); - await app.listen({ port: config.port, host: config.host }); - console.log(\`${PASCAL_NAME} server listening on \${config.host}:\${config.port}\`); - } catch (err) { - logger.error('Server failed to start', { error: err instanceof Error ? err.message : String(err) }); - process.exit(1); - } - }); - -// --------------------------------------------------------------------------- -// status command -// --------------------------------------------------------------------------- - -program - .command('status') - .description('Show ${PASCAL_NAME} plugin status') - .action(async () => { - try { - const config = loadConfig(); - const db = new ${PASCAL_NAME}Database(); - await db.connect(); - - const recordCount = await db.countRecords(); - const unprocessed = await db.getUnprocessedWebhooks(1); - - console.log('\n${PASCAL_NAME} Plugin Status'); - console.log('======================='); - console.log('Records: ', recordCount); - console.log('Unprocessed webhooks:', unprocessed.length > 0 ? '>0 (run sync)' : '0'); - - await db.disconnect(); - } catch (err) { - logger.error('Status check failed', { error: err instanceof Error ? err.message : String(err) }); - process.exit(1); - } - }); - -program.parse(process.argv); -CLI - chmod +x "${TARGET_DIR}/ts/src/cli.ts" -} - -# ============================================================================= -# Template: ts/src/index.ts -# ============================================================================= - -generate_ts_index() { - write_file "${TARGET_DIR}/ts/src/index.ts" <.ngrok.io/webhooks/${PLUGIN_NAME} -\`\`\` - -## Handler Script - -The Bash webhook handler is at \`webhooks/handler.sh\`. -It receives the raw payload via stdin and routes to event-specific scripts. -WEBHOOKSREADME -} - -# ============================================================================= -# Registry Entry — printed to stdout for manual addition to registry.json -# ============================================================================= - -print_registry_entry() { - printf "\n${BOLD}${CYAN}Registry Entry Template${RESET}\n" - printf "${CYAN}========================${RESET}\n" - printf "Add the following entry to ${PLUGINS_ROOT}/registry.json under \"plugins\":\n\n" - - cat </dev/null 2>&1; then - printf "\033[31mError: nself is not installed or not in PATH\033[0m\n" >&2 - exit 1 -fi - -# ============================================================================= -# Configuration -# ============================================================================= - -PLUGIN_DIR="${PLUGIN_DIR:-$HOME/.nself/plugins}" -PLUGIN_CACHE_DIR="${PLUGIN_CACHE_DIR:-$HOME/.nself/cache/plugins}" -PLUGIN_LOG_DIR="${PLUGIN_LOG_DIR:-$HOME/.nself/logs/plugins}" - -# ============================================================================= -# Logging Functions -# ============================================================================= - -plugin_log() { - local level="$1" - shift - local message="$*" - local timestamp - timestamp=$(date '+%Y-%m-%d %H:%M:%S') - - case "$level" in - debug) - [[ "${NSELF_DEBUG:-false}" == "true" ]] && printf "[%s] DEBUG: %s\n" "$timestamp" "$message" - ;; - info) - printf "[%s] INFO: %s\n" "$timestamp" "$message" - ;; - warn) - printf "\033[33m[%s] WARN: %s\033[0m\n" "$timestamp" "$message" - ;; - error) - printf "\033[31m[%s] ERROR: %s\033[0m\n" "$timestamp" "$message" >&2 - ;; - success) - printf "\033[32m[%s] %s\033[0m\n" "$timestamp" "$message" - ;; - esac -} - -plugin_debug() { plugin_log debug "$@"; } -plugin_info() { plugin_log info "$@"; } -plugin_warn() { plugin_log warn "$@"; } -plugin_error() { plugin_log error "$@"; } -plugin_success() { plugin_log success "$@"; } - -# ============================================================================= -# Environment Functions -# ============================================================================= - -# Load plugin environment variables from .env -plugin_load_env() { - local plugin_name="$1" - local env_file="${NSELF_PROJECT_DIR:-.}/.env" - - if [[ -f "$env_file" ]]; then - # Source only the plugin-specific variables - # Use tr for Bash 3.2 compatibility (no ${var^^}) - local plugin_upper - plugin_upper=$(printf '%s' "$plugin_name" | tr '[:lower:]' '[:upper:]') - - while IFS='=' read -r key value; do - # Skip comments and empty lines - [[ -z "$key" || "$key" =~ ^# ]] && continue - # Export the variable - export "$key=$value" - done < <(grep -E "^${plugin_upper}_" "$env_file" 2>/dev/null || true) - fi -} - -# Check if required environment variables are set -plugin_check_env() { - local plugin_name="$1" - shift - local required_vars=("$@") - local missing=() - - for var in "${required_vars[@]}"; do - if [[ -z "${!var:-}" ]]; then - missing+=("$var") - fi - done - - if [[ ${#missing[@]} -gt 0 ]]; then - plugin_error "Missing required environment variables:" - for var in "${missing[@]}"; do - printf " - %s\n" "$var" >&2 - done - return 1 - fi - - return 0 -} - -# ============================================================================= -# Database Functions -# ============================================================================= - -# Get database connection string -plugin_get_db_url() { - local db_host="${POSTGRES_HOST:-localhost}" - local db_port="${POSTGRES_PORT:-5432}" - local db_name="${POSTGRES_DB:-nself}" - local db_user="${POSTGRES_USER:-postgres}" - local db_pass="${POSTGRES_PASSWORD:-}" - - printf "postgresql://%s:%s@%s:%s/%s" "$db_user" "$db_pass" "$db_host" "$db_port" "$db_name" -} - -# Execute SQL query -plugin_db_query() { - local query="$1" - local db_url - db_url=$(plugin_get_db_url) - - if command -v psql >/dev/null 2>&1; then - psql "$db_url" -t -c "$query" 2>/dev/null - elif command -v docker >/dev/null 2>&1; then - docker exec -i "${PROJECT_NAME:-nself}_postgres" psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-nself}" -t -c "$query" 2>/dev/null - else - plugin_error "No PostgreSQL client available" - return 1 - fi -} - -# Execute SQL file -plugin_db_exec_file() { - local file="$1" - - if [[ ! -f "$file" ]]; then - plugin_error "SQL file not found: $file" - return 1 - fi - - if command -v psql >/dev/null 2>&1; then - psql "$(plugin_get_db_url)" -f "$file" 2>/dev/null - elif command -v docker >/dev/null 2>&1; then - docker exec -i "${PROJECT_NAME:-nself}_postgres" psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-nself}" < "$file" 2>/dev/null - else - plugin_error "No PostgreSQL client available" - return 1 - fi -} - -# Execute SQL statement without returning results -plugin_db_exec() { - local query="$1" - local db_url - db_url=$(plugin_get_db_url) - - if command -v psql >/dev/null 2>&1; then - psql "$db_url" -c "$query" >/dev/null 2>&1 - elif command -v docker >/dev/null 2>&1; then - docker exec -i "${PROJECT_NAME:-nself}_postgres" psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-nself}" -c "$query" >/dev/null 2>&1 - else - plugin_error "No PostgreSQL client available" - return 1 - fi -} - -# Check if table exists -plugin_table_exists() { - local table_name="$1" - local result - - result=$(plugin_db_query "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '$table_name');") - [[ "$result" =~ t ]] -} - -# ============================================================================= -# HTTP Functions -# ============================================================================= - -# Make HTTP request with curl -plugin_http_request() { - local method="$1" - local url="$2" - local data="${3:-}" - local headers=() - shift 3 || true - - # Remaining args are headers - while [[ $# -gt 0 ]]; do - headers+=("-H" "$1") - shift - done - - local curl_args=("-s" "-X" "$method") - - if [[ -n "$data" ]]; then - curl_args+=("-d" "$data") - fi - - if [[ ${#headers[@]} -gt 0 ]]; then - curl_args+=("${headers[@]}") - fi - - curl "${curl_args[@]}" "$url" -} - -plugin_http_get() { - plugin_http_request "GET" "$@" -} - -plugin_http_post() { - plugin_http_request "POST" "$@" -} - -# ============================================================================= -# Webhook Functions -# ============================================================================= - -# Verify webhook signature (generic HMAC-SHA256) -plugin_verify_webhook_signature() { - local payload="$1" - local signature="$2" - local secret="$3" - - local expected - expected=$(printf '%s' "$payload" | openssl dgst -sha256 -hmac "$secret" | sed 's/^.* //') - - [[ "$signature" == "$expected" ]] -} - -# Log webhook event -plugin_log_webhook() { - local plugin_name="$1" - local event_type="$2" - local event_id="$3" - local status="$4" - - local log_file="${PLUGIN_LOG_DIR}/${plugin_name}/webhooks.log" - mkdir -p "$(dirname "$log_file")" - - printf "%s | %s | %s | %s\n" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$event_type" "$event_id" "$status" >> "$log_file" -} - -# ============================================================================= -# Cache Functions -# ============================================================================= - -# Get cached value -plugin_cache_get() { - local plugin_name="$1" - local key="$2" - local ttl="${3:-3600}" # Default 1 hour TTL - - local cache_file="${PLUGIN_CACHE_DIR}/${plugin_name}/${key}" - - if [[ -f "$cache_file" ]]; then - local file_age - local current_time - current_time=$(date +%s) - - if [[ "$OSTYPE" == "darwin"* ]]; then - file_age=$(stat -f %m "$cache_file") - else - file_age=$(stat -c %Y "$cache_file") - fi - - if (( current_time - file_age < ttl )); then - cat "$cache_file" - return 0 - fi - fi - - return 1 -} - -# Set cached value -plugin_cache_set() { - local plugin_name="$1" - local key="$2" - local value="$3" - - local cache_dir="${PLUGIN_CACHE_DIR}/${plugin_name}" - mkdir -p "$cache_dir" - - printf '%s' "$value" > "${cache_dir}/${key}" -} - -# Clear plugin cache -plugin_cache_clear() { - local plugin_name="$1" - local cache_dir="${PLUGIN_CACHE_DIR}/${plugin_name}" - - if [[ -d "$cache_dir" ]]; then - rm -rf "$cache_dir" - plugin_info "Cleared cache for $plugin_name" - fi -} - -# ============================================================================= -# Validation Functions -# ============================================================================= - -# Validate plugin.json (basic manifest check — required fields present) -plugin_validate_manifest() { - local plugin_dir="$1" - local manifest="${plugin_dir}/plugin.json" - - if [[ ! -f "$manifest" ]]; then - plugin_error "plugin.json not found in $plugin_dir" - return 1 - fi - - # Check required fields - local required_fields=("name" "version" "description" "minNselfVersion") - for field in "${required_fields[@]}"; do - if ! grep -q "\"$field\"" "$manifest"; then - plugin_error "Missing required field in plugin.json: $field" - return 1 - fi - done - - return 0 -} - -# ============================================================================= -# Validate plugin.json — full schema validation -# ============================================================================= - -# validate_plugin_json [--quiet] -# -# Validates a plugin.json (the per-plugin manifest) against the nself schema: -# - Required fields present and non-empty: name, version, description, -# author, license, category, tags, tables -# - version follows semver (x.y.z) -# - category is one of the 13 official categories -# - all table names carry the np_ prefix -# - multiApp.isolationColumn is source_account_id (if multiApp is declared) -# - source_account_id column present in schema/tables.sql (if file exists) -# -# Note: plugin.json does NOT contain an 'implementation' field — that lives -# only in registry.json. Use validate-registry.sh for registry validation. -# -# Returns 0 if valid, 1 if any errors. Prints errors to stderr. -# With --quiet, suppresses all output (useful for CI gating). -# -# Bash 3.2+ compatible. -validate_plugin_json() { - local manifest="$1" - local quiet="${2:-}" - - # ------------------------------------------------------------------- - # File existence and JSON validity - # ------------------------------------------------------------------- - if [[ ! -f "$manifest" ]]; then - printf "\033[31m[ERROR]\033[0m plugin.json not found: %s\n" "$manifest" >&2 - return 1 - fi - - local _vp_py3="" - local _vp_py_candidate - for _vp_py_candidate in python3 /usr/bin/python3 /opt/homebrew/bin/python3; do - if command -v "$_vp_py_candidate" >/dev/null 2>&1; then - _vp_py3="$_vp_py_candidate" - break - fi - done - if [[ -z "$_vp_py3" ]]; then - plugin_error "python3 is required for validate_plugin_json" - return 1 - fi - - if ! "$_vp_py3" -c "import json, sys; json.load(open(sys.argv[1]))" "$manifest" 2>/dev/null; then - printf "\033[31m[ERROR]\033[0m %s: Invalid JSON — fix syntax errors first.\n" "$manifest" >&2 - return 1 - fi - - # ------------------------------------------------------------------- - # Extract fields to a temp file (avoids eval of JSON arrays/special chars) - # ------------------------------------------------------------------- - local _vp_tmpdir - _vp_tmpdir=$(mktemp -d 2>/dev/null || mktemp -d -t 'vpj') - local _vp_datafile="${_vp_tmpdir}/data" - - "$_vp_py3" - "$manifest" "$_vp_datafile" <<'PYEOF' -import json, sys - -with open(sys.argv[1]) as f: - p = json.load(f) - -outfile = sys.argv[2] -lines = [] - -def w(key, val): - if isinstance(val, (list, dict)): - lines.append(key + "=" + json.dumps(val, separators=(',', ':'))) - elif val is None: - lines.append(key + "=") - else: - lines.append(key + "=" + str(val)) - -w("VPJ_NAME", p.get("name", "")) -w("VPJ_VERSION", p.get("version", "")) -w("VPJ_DESCRIPTION", p.get("description", "")) -w("VPJ_AUTHOR", p.get("author", "")) -w("VPJ_LICENSE", p.get("license", "")) -w("VPJ_CATEGORY", p.get("category", "")) -w("VPJ_MINNSELF", p.get("minNselfVersion", "")) - -tags = p.get("tags") -lines.append("VPJ_TAGS_PRESENT=" + ("1" if tags is not None else "0")) -lines.append("VPJ_TAGS_COUNT=" + str(len(tags) if isinstance(tags, list) else 0)) - -tables = p.get("tables") -lines.append("VPJ_TABLES_PRESENT=" + ("1" if tables is not None else "0")) -lines.append("VPJ_TABLES_COUNT=" + str(len(tables) if isinstance(tables, list) else 0)) -w("VPJ_TABLES_JSON", tables if isinstance(tables, list) else []) - -multi = p.get("multiApp") -lines.append("VPJ_MULTI_PRESENT=" + ("1" if isinstance(multi, dict) else "0")) -w("VPJ_MULTI_ISOLATION", multi.get("isolationColumn","") if isinstance(multi, dict) else "") - -with open(outfile, 'w') as f: - f.write("\n".join(lines) + "\n") -PYEOF - - # Read fields from temp file (one key=value per line) - local _vp_field_reader - _vp_field_reader() { - grep "^${1}=" "$_vp_datafile" 2>/dev/null | head -1 | cut -d'=' -f2- - } - - local _vp_name; _vp_name=$(_vp_field_reader VPJ_NAME) - local _vp_version; _vp_version=$(_vp_field_reader VPJ_VERSION) - local _vp_description; _vp_description=$(_vp_field_reader VPJ_DESCRIPTION) - local _vp_author; _vp_author=$(_vp_field_reader VPJ_AUTHOR) - local _vp_license; _vp_license=$(_vp_field_reader VPJ_LICENSE) - local _vp_category; _vp_category=$(_vp_field_reader VPJ_CATEGORY) - local _vp_minnself; _vp_minnself=$(_vp_field_reader VPJ_MINNSELF) - local _vp_tags_present; _vp_tags_present=$(_vp_field_reader VPJ_TAGS_PRESENT) - local _vp_tags_count; _vp_tags_count=$(_vp_field_reader VPJ_TAGS_COUNT) - local _vp_tables_present; _vp_tables_present=$(_vp_field_reader VPJ_TABLES_PRESENT) - local _vp_tables_count; _vp_tables_count=$(_vp_field_reader VPJ_TABLES_COUNT) - local _vp_tables_json; _vp_tables_json=$(_vp_field_reader VPJ_TABLES_JSON) - local _vp_multi_present; _vp_multi_present=$(_vp_field_reader VPJ_MULTI_PRESENT) - local _vp_multi_isolation; _vp_multi_isolation=$(_vp_field_reader VPJ_MULTI_ISOLATION) - - rm -rf "$_vp_tmpdir" - - # Plugin directory (parent of manifest file) - local _vp_dir - _vp_dir="$(cd "$(dirname "$manifest")" && pwd)" - - # Error counter - local _vp_errors=0 - - # Valid categories - local _vp_valid_cats="authentication automation commerce communication content data development infrastructure integrations media streaming sports compliance" - - # Output helpers (use stderr to not pollute caller's stdout) - _vp_err_out() { - [[ "$quiet" == "--quiet" ]] && return 0 - printf "\033[31m[ERROR]\033[0m %s — %s: %s\n" "${_vp_name:-unknown}" "$1" "$2" >&2 - } - _vp_warn_out() { - [[ "$quiet" == "--quiet" ]] && return 0 - printf "\033[33m[WARN]\033[0m %s — %s: %s\n" "${_vp_name:-unknown}" "$1" "$2" >&2 - } - - # ------------------------------------------------------------------- - # 1. Required string fields - # ------------------------------------------------------------------- - local _vp_check_val - for _vp_check_val in \ - "name:$_vp_name" \ - "version:$_vp_version" \ - "description:$_vp_description" \ - "author:$_vp_author" \ - "license:$_vp_license" \ - "category:$_vp_category" - do - local _vp_fname="${_vp_check_val%%:*}" - local _vp_fval="${_vp_check_val#*:}" - if [[ -z "$_vp_fval" ]]; then - _vp_err_out "$_vp_fname" "Required field is missing or empty" - _vp_errors=$((_vp_errors + 1)) - fi - done - - # ------------------------------------------------------------------- - # 2. Semver: x.y.z (digits only) - # ------------------------------------------------------------------- - if [[ -n "$_vp_version" ]]; then - local _vp_vc="${_vp_version#v}" - local _vp_vmaj="${_vp_vc%%.*}" - local _vp_vrest="${_vp_vc#*.}" - local _vp_vmin="${_vp_vrest%%.*}" - local _vp_vpat="${_vp_vrest#*.}" - local _vp_ver_ok=true - [[ "$_vp_vc" != *.*.* ]] && _vp_ver_ok=false - [[ "$_vp_vmaj" =~ [^0-9] ]] && _vp_ver_ok=false - [[ "$_vp_vmin" =~ [^0-9] ]] && _vp_ver_ok=false - [[ "$_vp_vpat" =~ [^0-9] ]] && _vp_ver_ok=false - if [[ "$_vp_ver_ok" == "false" ]]; then - _vp_err_out "version" "Invalid semver: '$_vp_version' (expected x.y.z)" - _vp_errors=$((_vp_errors + 1)) - fi - fi - - # ------------------------------------------------------------------- - # 3. Category validation - # ------------------------------------------------------------------- - if [[ -n "$_vp_category" ]]; then - local _vp_cat_ok=false - local _vp_c - for _vp_c in $_vp_valid_cats; do - [[ "$_vp_c" == "$_vp_category" ]] && _vp_cat_ok=true && break - done - if [[ "$_vp_cat_ok" == "false" ]]; then - _vp_err_out "category" "Invalid: '$_vp_category'. Valid: $_vp_valid_cats" - _vp_errors=$((_vp_errors + 1)) - fi - fi - - # ------------------------------------------------------------------- - # 4. Tags — present and non-empty - # ------------------------------------------------------------------- - if [[ "${_vp_tags_present:-0}" == "0" ]]; then - _vp_err_out "tags" "Required field 'tags' is missing" - _vp_errors=$((_vp_errors + 1)) - elif [[ "${_vp_tags_count:-0}" == "0" ]]; then - _vp_err_out "tags" "tags array is empty — at least one tag required" - _vp_errors=$((_vp_errors + 1)) - fi - - # ------------------------------------------------------------------- - # 5. Tables — present; all entries carry np_ prefix - # ------------------------------------------------------------------- - if [[ "${_vp_tables_present:-0}" == "0" ]]; then - _vp_err_out "tables" "Required field 'tables' is missing" - _vp_errors=$((_vp_errors + 1)) - else - local _vp_table_list - _vp_table_list=$("$_vp_py3" -c " -import json, sys -tables = json.loads(sys.argv[1]) -for t in tables: - print(t) -" "$_vp_tables_json" 2>/dev/null) - - local _vp_table - while IFS= read -r _vp_table; do - [[ -z "$_vp_table" ]] && continue - case "$_vp_table" in - np_*) ;; - *) - _vp_err_out "tables" "Table '$_vp_table' missing np_ prefix" - _vp_errors=$((_vp_errors + 1)) - ;; - esac - done <<< "$_vp_table_list" - fi - - # ------------------------------------------------------------------- - # 6. SQL schema: source_account_id column required - # ------------------------------------------------------------------- - local _vp_sql="${_vp_dir}/schema/tables.sql" - if [[ -f "$_vp_sql" ]]; then - if ! grep -q "source_account_id" "$_vp_sql" 2>/dev/null; then - _vp_err_out "schema/tables.sql" "Missing source_account_id column (required for multi-app isolation)" - _vp_errors=$((_vp_errors + 1)) - fi - fi - - # ------------------------------------------------------------------- - # 7. multiApp isolation column must be source_account_id - # ------------------------------------------------------------------- - if [[ "${_vp_multi_present:-0}" == "1" ]]; then - if [[ "$_vp_multi_isolation" != "source_account_id" ]]; then - _vp_err_out "multiApp.isolationColumn" "Must be 'source_account_id', found: '$_vp_multi_isolation'" - _vp_errors=$((_vp_errors + 1)) - fi - fi - - # ------------------------------------------------------------------- - # 8. minNselfVersion — recommended - # ------------------------------------------------------------------- - if [[ -z "$_vp_minnself" ]]; then - _vp_warn_out "minNselfVersion" "Recommended field is missing" - fi - - # ------------------------------------------------------------------- - # Result - # ------------------------------------------------------------------- - if [[ $_vp_errors -eq 0 ]]; then - [[ "$quiet" != "--quiet" ]] && plugin_success "plugin.json valid: ${_vp_name} v${_vp_version}" - return 0 - else - [[ "$quiet" != "--quiet" ]] && plugin_error "plugin.json has $_vp_errors error(s): ${manifest}" - return 1 - fi -} - -# ============================================================================= -# Version Comparison -# ============================================================================= - -# Compare semantic versions -# Returns: 0 if v1 >= v2, 1 if v1 < v2 -plugin_version_gte() { - local v1="$1" - local v2="$2" - - # Remove 'v' prefix if present - v1="${v1#v}" - v2="${v2#v}" - - # Split into parts - IFS='.' read -ra V1_PARTS <<< "$v1" - IFS='.' read -ra V2_PARTS <<< "$v2" - - for i in 0 1 2; do - local p1="${V1_PARTS[$i]:-0}" - local p2="${V2_PARTS[$i]:-0}" - - if (( p1 > p2 )); then - return 0 - elif (( p1 < p2 )); then - return 1 - fi - done - - return 0 -} - -# ============================================================================= -# JSON Helpers (using grep/sed for Bash 3.2 compatibility) -# ============================================================================= - -# Extract value from JSON (simple single-level) -plugin_json_get() { - local json="$1" - local key="$2" - - printf '%s' "$json" | grep -o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | sed 's/.*: *"\([^"]*\)".*/\1/' | head -1 -} - -# Extract array from JSON -plugin_json_get_array() { - local json="$1" - local key="$2" - - printf '%s' "$json" | grep -o "\"$key\"[[:space:]]*:[[:space:]]*\[[^]]*\]" | sed 's/.*\[\([^]]*\)\].*/\1/' | tr ',' '\n' | sed 's/[" ]//g' -} - -# ============================================================================= -# Plugin Registry Functions -# ============================================================================= - -# Ensure plugin registry table exists -plugin_ensure_registry_table() { - plugin_db_exec " - CREATE TABLE IF NOT EXISTS _nself_plugin_registry ( - plugin_name VARCHAR(255) PRIMARY KEY, - version VARCHAR(50), - installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - status VARCHAR(50) DEFAULT 'active' - ); - " 2>/dev/null || true -} - -# Mark plugin as installed -plugin_mark_installed() { - local plugin_name="$1" - local version="${2:-1.0.0}" - - plugin_ensure_registry_table - - plugin_db_exec " - INSERT INTO _nself_plugin_registry (plugin_name, version, installed_at, status) - VALUES ('$plugin_name', '$version', CURRENT_TIMESTAMP, 'active') - ON CONFLICT (plugin_name) DO UPDATE SET - version = EXCLUDED.version, - updated_at = CURRENT_TIMESTAMP, - status = 'active'; - " - - plugin_debug "Marked $plugin_name as installed (version: $version)" -} - -# Mark plugin as uninstalled -plugin_mark_uninstalled() { - local plugin_name="$1" - - plugin_ensure_registry_table - - plugin_db_exec " - UPDATE _nself_plugin_registry - SET status = 'uninstalled', updated_at = CURRENT_TIMESTAMP - WHERE plugin_name = '$plugin_name'; - " - - plugin_debug "Marked $plugin_name as uninstalled" -} - -# Check if plugin is installed -plugin_is_installed() { - local plugin_name="$1" - - plugin_ensure_registry_table - - local result - result=$(plugin_db_query "SELECT COUNT(*) FROM _nself_plugin_registry WHERE plugin_name = '$plugin_name' AND status = 'active';") - [[ "$result" =~ [1-9] ]] -} diff --git a/shared/schema-sync.sh b/shared/schema-sync.sh deleted file mode 100644 index b8a696a37..000000000 --- a/shared/schema-sync.sh +++ /dev/null @@ -1,262 +0,0 @@ -#!/bin/bash -# ============================================================================= -# nself Plugin Schema Sync -# Database schema management for nself plugins -# ============================================================================= - -set -euo pipefail - -# Source plugin utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/plugin-utils.sh" - -# ============================================================================= -# Schema Management -# ============================================================================= - -# Apply plugin schema -schema_apply() { - local plugin_name="$1" - local plugin_dir="${PLUGIN_DIR}/${plugin_name}" - local schema_dir="${plugin_dir}/schema" - - if [[ ! -d "$schema_dir" ]]; then - plugin_warn "No schema directory found for $plugin_name" - return 0 - fi - - plugin_info "Applying schema for $plugin_name..." - - # Apply tables.sql if exists - if [[ -f "${schema_dir}/tables.sql" ]]; then - plugin_debug "Applying tables.sql" - plugin_db_exec_file "${schema_dir}/tables.sql" - fi - - # Apply migrations in order - if [[ -d "${schema_dir}/migrations" ]]; then - for migration in "${schema_dir}/migrations"/*.sql; do - [[ ! -f "$migration" ]] && continue - - local migration_name - migration_name=$(basename "$migration" .sql) - - # Check if migration already applied - if schema_migration_applied "$plugin_name" "$migration_name"; then - plugin_debug "Migration already applied: $migration_name" - continue - fi - - plugin_info "Applying migration: $migration_name" - plugin_db_exec_file "$migration" - schema_record_migration "$plugin_name" "$migration_name" - done - fi - - plugin_success "Schema applied for $plugin_name" -} - -# Rollback plugin schema -schema_rollback() { - local plugin_name="$1" - local steps="${2:-1}" - local plugin_dir="${PLUGIN_DIR}/${plugin_name}" - local schema_dir="${plugin_dir}/schema" - - if [[ ! -d "${schema_dir}/rollbacks" ]]; then - plugin_error "No rollback scripts found for $plugin_name" - return 1 - fi - - plugin_info "Rolling back $steps migration(s) for $plugin_name..." - - # Get applied migrations in reverse order - local applied_migrations - applied_migrations=$(plugin_db_query "SELECT migration_name FROM _nself_plugin_migrations WHERE plugin_name = '$plugin_name' ORDER BY applied_at DESC LIMIT $steps;") - - while IFS= read -r migration_name; do - [[ -z "$migration_name" ]] && continue - migration_name=$(echo "$migration_name" | xargs) - - local rollback_file="${schema_dir}/rollbacks/${migration_name}.sql" - - if [[ -f "$rollback_file" ]]; then - plugin_info "Rolling back: $migration_name" - plugin_db_exec_file "$rollback_file" - schema_remove_migration "$plugin_name" "$migration_name" - else - plugin_warn "No rollback script for: $migration_name" - fi - done <<< "$applied_migrations" - - plugin_success "Rollback complete" -} - -# Check migration status -schema_status() { - local plugin_name="$1" - local plugin_dir="${PLUGIN_DIR}/${plugin_name}" - local schema_dir="${plugin_dir}/schema" - - printf "\n=== Schema Status: %s ===\n\n" "$plugin_name" - - # List applied migrations - printf "Applied migrations:\n" - plugin_db_query "SELECT migration_name, applied_at FROM _nself_plugin_migrations WHERE plugin_name = '$plugin_name' ORDER BY applied_at;" - - # List pending migrations - if [[ -d "${schema_dir}/migrations" ]]; then - printf "\nPending migrations:\n" - for migration in "${schema_dir}/migrations"/*.sql; do - [[ ! -f "$migration" ]] && continue - - local migration_name - migration_name=$(basename "$migration" .sql) - - if ! schema_migration_applied "$plugin_name" "$migration_name"; then - printf " - %s\n" "$migration_name" - fi - done - fi - - # List tables - printf "\nPlugin tables:\n" - plugin_db_query "SELECT table_name FROM information_schema.tables WHERE table_name LIKE '${plugin_name}_%' ORDER BY table_name;" -} - -# ============================================================================= -# Migration Tracking -# ============================================================================= - -# Ensure migrations table exists -schema_ensure_migrations_table() { - plugin_db_exec " - CREATE TABLE IF NOT EXISTS _nself_plugin_migrations ( - id SERIAL PRIMARY KEY, - plugin_name VARCHAR(255) NOT NULL, - migration_name VARCHAR(255) NOT NULL, - applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(plugin_name, migration_name) - ); - " 2>/dev/null || true -} - -# Check if migration is applied -schema_migration_applied() { - local plugin_name="$1" - local migration_name="$2" - - schema_ensure_migrations_table - - local result - result=$(plugin_db_query "SELECT COUNT(*) FROM _nself_plugin_migrations WHERE plugin_name = '$plugin_name' AND migration_name = '$migration_name';") - [[ "$result" =~ [1-9] ]] -} - -# Record applied migration -schema_record_migration() { - local plugin_name="$1" - local migration_name="$2" - - schema_ensure_migrations_table - - plugin_db_exec "INSERT INTO _nself_plugin_migrations (plugin_name, migration_name) VALUES ('$plugin_name', '$migration_name');" -} - -# Remove migration record -schema_remove_migration() { - local plugin_name="$1" - local migration_name="$2" - - plugin_db_exec "DELETE FROM _nself_plugin_migrations WHERE plugin_name = '$plugin_name' AND migration_name = '$migration_name';" -} - -# Remove all migration records for a plugin -schema_remove_plugin_migrations() { - local plugin_name="$1" - - schema_ensure_migrations_table - - plugin_db_exec "DELETE FROM _nself_plugin_migrations WHERE plugin_name = '$plugin_name';" - plugin_debug "Removed all migration records for $plugin_name" -} - -# ============================================================================= -# Schema Generation -# ============================================================================= - -# Generate schema from API response (for data sync plugins) -schema_generate_from_api() { - local plugin_name="$1" - local api_response="$2" - local table_name="$3" - - # Generate PostgreSQL CREATE TABLE from JSON payload using jq. - # Maps: string→TEXT, number→NUMERIC, boolean→BOOLEAN, object/array→JSONB, null→TEXT - if ! command -v jq >/dev/null 2>&1; then - plugin_warn "jq is required for schema_generate_from_api" - return 1 - fi - - local prefix="np_${plugin_name}_" - local full_table="${prefix}${table_name}" - - # Escape SQL identifier - local safe_table - safe_table=$(printf '%s' "$full_table" | tr -cd 'a-z0-9_') - - # Generate columns from JSON keys - local columns - columns=$(printf '%s' "$api_response" | jq -r ' - to_entries | map( - .key as $k | .value | - if type == "string" then "\($k) TEXT" - elif type == "number" then - if . == (. | floor) then "\($k) BIGINT" - else "\($k) NUMERIC" - end - elif type == "boolean" then "\($k) BOOLEAN" - elif type == "object" or type == "array" then "\($k) JSONB" - else "\($k) TEXT" - end - ) | join(",\n ") - ') - - if [ -z "$columns" ]; then - plugin_warn "No columns could be derived from API response" - return 1 - fi - - printf 'CREATE TABLE IF NOT EXISTS %s (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n source_account_id TEXT NOT NULL DEFAULT '"'"'primary'"'"',\n %s,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n' "$safe_table" "$columns" -} - -# ============================================================================= -# Main -# ============================================================================= - -schema_main() { - local action="${1:-}" - local plugin_name="${2:-}" - - case "$action" in - apply) - schema_apply "$plugin_name" - ;; - rollback) - schema_rollback "$plugin_name" "${3:-1}" - ;; - status) - schema_status "$plugin_name" - ;; - *) - printf "Usage: schema-sync.sh [args...]\n" - printf "Actions: apply, rollback, status\n" - return 1 - ;; - esac -} - -# Run if executed directly -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - schema_main "$@" -fi diff --git a/shared/webhook-handler.sh b/shared/webhook-handler.sh deleted file mode 100644 index 3a80ff730..000000000 --- a/shared/webhook-handler.sh +++ /dev/null @@ -1,192 +0,0 @@ -#!/bin/bash -# ============================================================================= -# nself Plugin Webhook Handler -# Generic webhook processing for nself plugins -# ============================================================================= - -set -euo pipefail - -# Source plugin utilities -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "${SCRIPT_DIR}/plugin-utils.sh" - -# ============================================================================= -# Configuration -# ============================================================================= - -WEBHOOK_PORT="${WEBHOOK_PORT:-8088}" -WEBHOOK_HOST="${WEBHOOK_HOST:-0.0.0.0}" -WEBHOOK_LOG_LEVEL="${WEBHOOK_LOG_LEVEL:-info}" - -# ============================================================================= -# Webhook Server Functions -# ============================================================================= - -# Start webhook listener using netcat (for development/testing) -webhook_start_listener() { - local plugin_name="$1" - local handler_script="$2" - - plugin_info "Starting webhook listener for $plugin_name on port $WEBHOOK_PORT" - - while true; do - # Read HTTP request - local request="" - local content_length=0 - local body="" - - { - # Read headers - while IFS= read -r line; do - line="${line%$'\r'}" - [[ -z "$line" ]] && break - - if [[ "$line" =~ ^Content-Length:\ ([0-9]+) ]]; then - content_length="${BASH_REMATCH[1]}" - fi - - request+="$line"$'\n' - done - - # Read body - if [[ $content_length -gt 0 ]]; then - body=$(head -c "$content_length") - fi - - # Process webhook - local response - if response=$("$handler_script" "$body" 2>&1); then - printf "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"received\":true}" - else - printf "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n{\"error\":\"%s\"}" "$response" - fi - - } | nc -l "$WEBHOOK_PORT" - done -} - -# ============================================================================= -# Webhook Processing -# ============================================================================= - -# Process incoming webhook -webhook_process() { - local plugin_name="$1" - local event_type="$2" - local payload="$3" - local signature="${4:-}" - - plugin_debug "Processing webhook: $plugin_name/$event_type" - - # Find and execute the event handler - local handler_dir="${PLUGIN_DIR}/${plugin_name}/webhooks/events" - local event_file - event_file=$(echo "$event_type" | tr '.' '_') - - if [[ -f "${handler_dir}/${event_file}.sh" ]]; then - plugin_debug "Executing handler: ${event_file}.sh" - bash "${handler_dir}/${event_file}.sh" "$payload" - elif [[ -f "${handler_dir}/default.sh" ]]; then - plugin_debug "Executing default handler" - bash "${handler_dir}/default.sh" "$event_type" "$payload" - else - plugin_warn "No handler found for event: $event_type" - return 0 - fi -} - -# ============================================================================= -# Webhook Queue (for reliable processing) -# ============================================================================= - -WEBHOOK_QUEUE_DIR="${WEBHOOK_QUEUE_DIR:-$HOME/.nself/queue/webhooks}" - -# Add webhook to queue -webhook_queue_add() { - local plugin_name="$1" - local event_type="$2" - local payload="$3" - - local queue_dir="${WEBHOOK_QUEUE_DIR}/${plugin_name}" - mkdir -p "$queue_dir" - - local event_id - event_id=$(date +%s%N) - local event_file="${queue_dir}/${event_id}.json" - - printf '{"event_type":"%s","payload":%s,"queued_at":"%s"}' \ - "$event_type" "$payload" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$event_file" - - plugin_debug "Queued webhook event: $event_id" - printf '%s' "$event_id" -} - -# Process webhook queue -webhook_queue_process() { - local plugin_name="$1" - local queue_dir="${WEBHOOK_QUEUE_DIR}/${plugin_name}" - - [[ ! -d "$queue_dir" ]] && return 0 - - local processed=0 - local failed=0 - - for event_file in "$queue_dir"/*.json; do - [[ ! -f "$event_file" ]] && continue - - local event_data - event_data=$(cat "$event_file") - - local event_type - local payload - event_type=$(plugin_json_get "$event_data" "event_type") - payload=$(printf '%s' "$event_data" | grep -o '"payload":[^}]*}' | sed 's/"payload"://') - - if webhook_process "$plugin_name" "$event_type" "$payload"; then - rm -f "$event_file" - ((processed++)) - else - ((failed++)) - fi - done - - plugin_info "Processed $processed webhooks, $failed failed" -} - -# ============================================================================= -# Main -# ============================================================================= - -webhook_main() { - local action="${1:-}" - local plugin_name="${2:-}" - - case "$action" in - start) - webhook_start_listener "$plugin_name" "${3:-}" - ;; - process) - local event_type="$3" - local payload="$4" - webhook_process "$plugin_name" "$event_type" "$payload" - ;; - queue-add) - local event_type="$3" - local payload="$4" - webhook_queue_add "$plugin_name" "$event_type" "$payload" - ;; - queue-process) - webhook_queue_process "$plugin_name" - ;; - *) - printf "Usage: webhook-handler.sh [args...]\n" - printf "Actions: start, process, queue-add, queue-process\n" - return 1 - ;; - esac -} - -# Run if executed directly -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - webhook_main "$@" -fi From 6182ca60f42d59be067aaeef25747ac0691e3c2f Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 25 Oct 2026 15:09:51 -0400 Subject: [PATCH 4/5] fix(boot-migrations): bound the remaining /health status calls and refresh the alpha golden (D-0887) Ten hand-placed helpers outside */cmd and */internal still called migrate.Status on context.Background(). Bound them with the same 5 s timeout, regenerate the alpha conformance golden, and widen the guard test to walk every Go file under free/ and tests/conformance/expected/. --- free/audit-analytics/boot_migrations.go | 5 +++- free/hipaa/go/boot_migrations.go | 5 +++- free/job-queue/go/api/boot_migrations.go | 5 +++- free/nself-cloud/boot_migrations.go | 6 ++++- free/nself-eval-gate/boot_migrations.go | 6 ++++- .../cmd/nself-sync/boot_migrations.go | 5 +++- .../cmd/nself-vault/boot_migrations.go | 6 ++++- .../cmd/plugin-pty/boot_migrations.go | 5 +++- free/siem/internal/db/boot_migrations.go | 6 ++++- .../internal/db/boot_migrations.go | 6 ++++- .../codemods/bootmig/health_timeout_test.go | 24 ++++++++++++------- .../expected/alpha/cmd/boot_migrations.go | 6 ++++- 12 files changed, 66 insertions(+), 19 deletions(-) diff --git a/free/audit-analytics/boot_migrations.go b/free/audit-analytics/boot_migrations.go index b0f023ee0..820711d1a 100644 --- a/free/audit-analytics/boot_migrations.go +++ b/free/audit-analytics/boot_migrations.go @@ -43,7 +43,10 @@ func bootMigrationsHealth(w http.ResponseWriter) map[string]int { if bootPool == nil { // handlers built without mustBootMigrations (unit tests) return nil } - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("audit-analytics: migrations: status: %v", err) w.Header().Set("Content-Type", "application/json") diff --git a/free/hipaa/go/boot_migrations.go b/free/hipaa/go/boot_migrations.go index 6fa4571eb..fa4ba4492 100644 --- a/free/hipaa/go/boot_migrations.go +++ b/free/hipaa/go/boot_migrations.go @@ -37,7 +37,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/job-queue/go/api/boot_migrations.go b/free/job-queue/go/api/boot_migrations.go index df1c2e919..d25cc3316 100644 --- a/free/job-queue/go/api/boot_migrations.go +++ b/free/job-queue/go/api/boot_migrations.go @@ -31,7 +31,10 @@ func bootOptions() migrate.Options { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { bootLog.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/nself-cloud/boot_migrations.go b/free/nself-cloud/boot_migrations.go index 66b19cdea..78c843d40 100644 --- a/free/nself-cloud/boot_migrations.go +++ b/free/nself-cloud/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -33,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/nself-eval-gate/boot_migrations.go b/free/nself-eval-gate/boot_migrations.go index 86bbd0482..65263b795 100644 --- a/free/nself-eval-gate/boot_migrations.go +++ b/free/nself-eval-gate/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -33,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/nself-sync/cmd/nself-sync/boot_migrations.go b/free/nself-sync/cmd/nself-sync/boot_migrations.go index 3a00b1b88..8ea1381c6 100644 --- a/free/nself-sync/cmd/nself-sync/boot_migrations.go +++ b/free/nself-sync/cmd/nself-sync/boot_migrations.go @@ -38,7 +38,10 @@ func handleHealth(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") body := map[string]any{"status": "ok"} if bootPool != nil { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/nself-vault/cmd/nself-vault/boot_migrations.go b/free/nself-vault/cmd/nself-vault/boot_migrations.go index f38034d6f..2b5ea97d0 100644 --- a/free/nself-vault/cmd/nself-vault/boot_migrations.go +++ b/free/nself-vault/cmd/nself-vault/boot_migrations.go @@ -7,6 +7,7 @@ import ( "encoding/json" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -35,7 +36,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { func handleHealth(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") body := map[string]any{"status": "ok"} - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/plugin-pty/cmd/plugin-pty/boot_migrations.go b/free/plugin-pty/cmd/plugin-pty/boot_migrations.go index 31e4d6155..0fbe2e245 100644 --- a/free/plugin-pty/cmd/plugin-pty/boot_migrations.go +++ b/free/plugin-pty/cmd/plugin-pty/boot_migrations.go @@ -34,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/siem/internal/db/boot_migrations.go b/free/siem/internal/db/boot_migrations.go index 8774355ff..fb51c1f0d 100644 --- a/free/siem/internal/db/boot_migrations.go +++ b/free/siem/internal/db/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -33,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func MigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/free/tenant-controller/internal/db/boot_migrations.go b/free/tenant-controller/internal/db/boot_migrations.go index 2fb8ac8f9..6cc533dc9 100644 --- a/free/tenant-controller/internal/db/boot_migrations.go +++ b/free/tenant-controller/internal/db/boot_migrations.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -39,7 +40,10 @@ func MigrationsHealth(w http.ResponseWriter) map[string]int { if bootPool == nil { // unit tests build handlers without a pool return nil } - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) diff --git a/scripts/codemods/bootmig/health_timeout_test.go b/scripts/codemods/bootmig/health_timeout_test.go index 63624b26a..97c9454cc 100644 --- a/scripts/codemods/bootmig/health_timeout_test.go +++ b/scripts/codemods/bootmig/health_timeout_test.go @@ -1,6 +1,7 @@ package main import ( + "io/fs" "os" "path/filepath" "regexp" @@ -23,16 +24,23 @@ func TestHelperTemplateBoundsStatus(t *testing.T) { } } -// The generated copies in the plugins carry the same fix. +// The generated copies in the plugins and the conformance goldens carry the same fix. Every Go file is +// scanned (not just */cmd and */internal): hand-placed helpers live at other depths (D-0887). func TestGeneratedHelpersBoundStatus(t *testing.T) { - root := filepath.Join("..", "..", "..", "free") var files []string - for _, pat := range []string{"*/internal/boot_migrations.go", "*/cmd/boot_migrations.go"} { - m, err := filepath.Glob(filepath.Join(root, pat)) - if err != nil { - t.Fatal(err) - } - files = append(files, m...) + for _, root := range []string{filepath.Join("..", "..", "..", "free"), filepath.Join("..", "..", "..", "tests", "conformance", "expected")} { + _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() && (d.Name() == "vendor" || d.Name() == "node_modules") { + return filepath.SkipDir + } + if !d.IsDir() && strings.HasSuffix(p, ".go") { + files = append(files, p) + } + return nil + }) } if len(files) == 0 { t.Skip("no plugin tree next to the codemod") diff --git a/tests/conformance/expected/alpha/cmd/boot_migrations.go b/tests/conformance/expected/alpha/cmd/boot_migrations.go index 22849bc6f..991e3e9d3 100644 --- a/tests/conformance/expected/alpha/cmd/boot_migrations.go +++ b/tests/conformance/expected/alpha/cmd/boot_migrations.go @@ -6,6 +6,7 @@ import ( "context" "log" "net/http" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/nself-org/cli/sdk/go/v2/migrate" @@ -33,7 +34,10 @@ func mustBootMigrations(ctx context.Context, pool *pgxpool.Pool) { // On a Status error it logs, answers 503 (the cli retries any non-200; a 200 with -1 counts is E119, // a contract violation) and returns -1 counts for humans. Call it before the handler writes a header. func bootMigrationsHealth(w http.ResponseWriter) map[string]int { - st, err := migrate.Status(context.Background(), bootPool, bootOptions()) + // A hung database must fail the probe, not hang it. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := migrate.Status(ctx, bootPool, bootOptions()) if err != nil { log.Printf("migrations: status: %v", err) w.WriteHeader(http.StatusServiceUnavailable) From 5e0804e035a40567b5e78ff1de92a9fb322f557a Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 26 Oct 2026 06:11:52 -0400 Subject: [PATCH 5/5] chore(file-processing): drop the install-ts test for the deleted v1 install.sh (D-0297) --- free/file-processing/tests/install-ts.test.sh | 37 ------------------- 1 file changed, 37 deletions(-) delete mode 100755 free/file-processing/tests/install-ts.test.sh diff --git a/free/file-processing/tests/install-ts.test.sh b/free/file-processing/tests/install-ts.test.sh deleted file mode 100755 index 14dcf5270..000000000 --- a/free/file-processing/tests/install-ts.test.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -# D-0623: install.sh must fail when the TypeScript install or build fails, and -# must use pnpm (npm cannot install the link: dependency on shared/). -# Runs install_typescript_dependencies against a stub pnpm; no network. -set -uo pipefail - -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -STUBS="$(mktemp -d "${TMPDIR:-/tmp}/fp-install-test.XXXXXX")" -trap 'rm -rf "$STUBS"' EXIT -FAILED=0 - -# shellcheck source=../install.sh -source "$HERE/../install.sh" -set +e # install.sh sets -e; the cases below expect non-zero returns - -# stub : a pnpm that logs its arguments and fails on "" ("" = never). -stub() { - printf '#!/bin/bash\necho "$PWD :: $*" >> "%s/calls"\n[ "$1 $2" = "%s" ] && exit 1\nexit 0\n' "$STUBS" "$1" > "$STUBS/pnpm" - chmod +x "$STUBS/pnpm" - : > "$STUBS/calls" -} - -check() { # check - if [ "$2" != "$3" ]; then echo "FAIL $1: rc $3, want $2"; FAILED=1; else echo "ok $1"; fi -} - -run() { (PATH="$STUBS:/usr/bin:/bin"; install_typescript_dependencies) >/dev/null 2>&1; } - -stub ""; run; check "all steps pass" 0 $? -grep -q ":: run build" "$STUBS/calls" || { echo "FAIL build was not run"; FAILED=1; } - -stub "install --frozen-lockfile"; run; check "install failure fails the installer" 1 $? -stub "run build"; run; check "build failure fails the installer" 1 $? - -rm -f "$STUBS/pnpm"; run; check "missing pnpm fails the installer" 1 $? - -exit "$FAILED"