gemstack-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +24 -0
  5. data/exe/gemstack +6 -0
  6. data/lib/gemstack/cli/add_generator.rb +237 -0
  7. data/lib/gemstack/cli/app_generator.rb +110 -0
  8. data/lib/gemstack/cli/commands/db.rb +134 -0
  9. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  10. data/lib/gemstack/cli/controller_generator.rb +73 -0
  11. data/lib/gemstack/cli/deploy_generator.rb +64 -0
  12. data/lib/gemstack/cli/doctor.rb +277 -0
  13. data/lib/gemstack/cli/generator.rb +122 -0
  14. data/lib/gemstack/cli/job_generator.rb +47 -0
  15. data/lib/gemstack/cli/migration_generator.rb +52 -0
  16. data/lib/gemstack/cli/policy_generator.rb +35 -0
  17. data/lib/gemstack/cli/project.rb +53 -0
  18. data/lib/gemstack/cli/resource_generator.rb +169 -0
  19. data/lib/gemstack/cli/resource_spec.rb +95 -0
  20. data/lib/gemstack/cli.rb +299 -0
  21. data/templates/app/Gemfile.tt +39 -0
  22. data/templates/app/README.md.tt +29 -0
  23. data/templates/app/app/controllers/application_controller.rb +6 -0
  24. data/templates/app/bin/gemstack +7 -0
  25. data/templates/app/config/app.rb.tt +30 -0
  26. data/templates/app/config/environments/development.rb +6 -0
  27. data/templates/app/config/environments/production.rb +8 -0
  28. data/templates/app/config/environments/test.rb +7 -0
  29. data/templates/app/config/puma.rb +21 -0
  30. data/templates/app/config/routes.rb +10 -0
  31. data/templates/app/config.ru +6 -0
  32. data/templates/app/db/migrations/dot_keep +0 -0
  33. data/templates/app/db/seeds.rb +5 -0
  34. data/templates/app/dot_env.example +9 -0
  35. data/templates/app/dot_gitignore +14 -0
  36. data/templates/app/dot_ruby-version.tt +1 -0
  37. data/templates/app/dot_tool-versions.tt +1 -0
  38. data/templates/app/test/health_test.rb +18 -0
  39. data/templates/app/test/test_helper.rb.tt +25 -0
  40. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  41. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  42. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  43. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  44. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  45. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  46. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  47. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  48. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  49. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  50. data/templates/auth/app/models/auth_token.rb +15 -0
  51. data/templates/auth/app/models/user.rb +12 -0
  52. data/templates/auth/app/policies/application_policy.rb +6 -0
  53. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  54. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  55. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  56. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  57. data/templates/auth/frontend/app/account/page.tsx +116 -0
  58. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  59. data/templates/auth/frontend/app/login/page.tsx +26 -0
  60. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  61. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  62. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  63. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  64. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  65. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  66. data/templates/auth/frontend/lib/auth.ts +113 -0
  67. data/templates/auth/test/controllers/auth_test.rb +108 -0
  68. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  69. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  70. data/templates/deploy/Caddyfile.tt +19 -0
  71. data/templates/deploy/Dockerfile.tt +62 -0
  72. data/templates/deploy/Procfile.tt +6 -0
  73. data/templates/deploy/compose.yaml.tt +80 -0
  74. data/templates/deploy/dot_dockerignore +14 -0
  75. data/templates/frontend/app/globals.css +171 -0
  76. data/templates/frontend/app/layout.tsx.tt +19 -0
  77. data/templates/frontend/app/page.tsx.tt +45 -0
  78. data/templates/frontend/app/providers.tsx +24 -0
  79. data/templates/frontend/lib/gemstack/client.ts +125 -0
  80. data/templates/frontend/next-env.d.ts +5 -0
  81. data/templates/frontend/next.config.ts +17 -0
  82. data/templates/frontend/package.json.tt +23 -0
  83. data/templates/frontend/tsconfig.json +21 -0
  84. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  85. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  86. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  87. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  88. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  89. data/templates/realtime/config/channels.rb +14 -0
  90. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  91. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  92. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  93. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  94. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  95. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  96. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  97. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  98. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  99. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  100. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  101. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +63 -0
  102. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  103. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  104. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  105. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  106. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  107. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  108. data/templates/storage/frontend/lib/upload.ts +52 -0
  109. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  110. metadata +195 -0
@@ -0,0 +1,48 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useState, type FormEvent } from "react";
5
+ import { errorMessage, useForgotPassword } from "@/lib/auth";
6
+
7
+ export default function ForgotPasswordPage() {
8
+ const [email, setEmail] = useState("");
9
+ const forgot = useForgotPassword();
10
+
11
+ function handleSubmit(event: FormEvent<HTMLFormElement>) {
12
+ event.preventDefault();
13
+ forgot.mutate(email);
14
+ }
15
+
16
+ if (forgot.isSuccess) {
17
+ return (
18
+ <main className="page">
19
+ <h1>Check your email</h1>
20
+ <p>If an account exists for {email}, we sent it a link to choose a new password.</p>
21
+ <Link href="/login">Back to log in</Link>
22
+ </main>
23
+ );
24
+ }
25
+
26
+ const message = errorMessage(forgot.error);
27
+ return (
28
+ <main className="page">
29
+ <h1>Reset your password</h1>
30
+ <form className="form" onSubmit={handleSubmit}>
31
+ <label className="field">
32
+ Email
33
+ <input type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
34
+ </label>
35
+ {message && (
36
+ <p className="form-error" role="alert">
37
+ {message}
38
+ </p>
39
+ )}
40
+ <div className="actions">
41
+ <button className="button" type="submit" disabled={forgot.isPending}>
42
+ Send reset link
43
+ </button>
44
+ </div>
45
+ </form>
46
+ </main>
47
+ );
48
+ }
@@ -0,0 +1,26 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { CredentialsForm } from "@/components/auth/CredentialsForm";
6
+ import { useLogin } from "@/lib/auth";
7
+
8
+ export default function LoginPage() {
9
+ const router = useRouter();
10
+ const login = useLogin();
11
+
12
+ return (
13
+ <main className="page">
14
+ <h1>Log in</h1>
15
+ <CredentialsForm
16
+ submitLabel="Log in"
17
+ error={login.error}
18
+ pending={login.isPending}
19
+ onSubmit={(credentials) => login.mutate(credentials, { onSuccess: () => router.push("/account") })}
20
+ />
21
+ <p className="muted">
22
+ <Link href="/forgot-password">Forgot your password?</Link> · No account? <Link href="/signup">Sign up</Link>
23
+ </p>
24
+ </main>
25
+ );
26
+ }
@@ -0,0 +1,11 @@
1
+ import { ResetPasswordForm } from "@/components/auth/ResetPasswordForm";
2
+
3
+ export default async function ResetPasswordPage({ searchParams }: { searchParams: Promise<{ token?: string }> }) {
4
+ const { token } = await searchParams;
5
+ return (
6
+ <main className="page">
7
+ <h1>Choose a new password</h1>
8
+ {token ? <ResetPasswordForm token={token} /> : <p className="form-error">This link is missing its token.</p>}
9
+ </main>
10
+ );
11
+ }
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { CredentialsForm } from "@/components/auth/CredentialsForm";
6
+ import { useSignup } from "@/lib/auth";
7
+
8
+ export default function SignupPage() {
9
+ const router = useRouter();
10
+ const signup = useSignup();
11
+
12
+ return (
13
+ <main className="page">
14
+ <h1>Create an account</h1>
15
+ <CredentialsForm
16
+ submitLabel="Sign up"
17
+ newPassword
18
+ error={signup.error}
19
+ pending={signup.isPending}
20
+ onSubmit={(credentials) => signup.mutate(credentials, { onSuccess: () => router.push("/account") })}
21
+ />
22
+ <p className="muted">
23
+ Already have an account? <Link href="/login">Log in</Link>
24
+ </p>
25
+ </main>
26
+ );
27
+ }
@@ -0,0 +1,11 @@
1
+ import { VerifyEmail } from "@/components/auth/VerifyEmail";
2
+
3
+ export default async function VerifyEmailPage({ searchParams }: { searchParams: Promise<{ token?: string }> }) {
4
+ const { token } = await searchParams;
5
+ return (
6
+ <main className="page">
7
+ <h1>Confirm your email</h1>
8
+ {token ? <VerifyEmail token={token} /> : <p className="form-error">This link is missing its token.</p>}
9
+ </main>
10
+ );
11
+ }
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import { useState, type FormEvent } from "react";
4
+ import { errorMessage, type Credentials } from "@/lib/auth";
5
+
6
+ export function CredentialsForm({
7
+ submitLabel,
8
+ newPassword = false,
9
+ error,
10
+ pending,
11
+ onSubmit,
12
+ }: {
13
+ submitLabel: string;
14
+ newPassword?: boolean;
15
+ error: unknown;
16
+ pending: boolean;
17
+ onSubmit: (credentials: Credentials) => void;
18
+ }) {
19
+ const [email, setEmail] = useState("");
20
+ const [password, setPassword] = useState("");
21
+
22
+ function handleSubmit(event: FormEvent<HTMLFormElement>) {
23
+ event.preventDefault();
24
+ onSubmit({ email, password });
25
+ }
26
+
27
+ const message = errorMessage(error);
28
+ return (
29
+ <form className="form" onSubmit={handleSubmit}>
30
+ <label className="field">
31
+ Email
32
+ <input type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
33
+ </label>
34
+ <label className="field">
35
+ Password
36
+ <input
37
+ type="password"
38
+ autoComplete={newPassword ? "new-password" : "current-password"}
39
+ minLength={newPassword ? 12 : undefined}
40
+ required
41
+ value={password}
42
+ onChange={(e) => setPassword(e.target.value)}
43
+ />
44
+ {newPassword && <small className="muted">At least 12 characters. A few random words work well.</small>}
45
+ </label>
46
+ {message && (
47
+ <p className="form-error" role="alert">
48
+ {message}
49
+ </p>
50
+ )}
51
+ <div className="actions">
52
+ <button className="button" type="submit" disabled={pending}>
53
+ {pending ? "Please wait…" : submitLabel}
54
+ </button>
55
+ </div>
56
+ </form>
57
+ );
58
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useState, type FormEvent } from "react";
5
+ import { errorMessage, useResetPassword } from "@/lib/auth";
6
+
7
+ export function ResetPasswordForm({ token }: { token: string }) {
8
+ const router = useRouter();
9
+ const [password, setPassword] = useState("");
10
+ const reset = useResetPassword();
11
+
12
+ function handleSubmit(event: FormEvent<HTMLFormElement>) {
13
+ event.preventDefault();
14
+ reset.mutate({ token, password }, { onSuccess: () => router.push("/account") });
15
+ }
16
+
17
+ const message = errorMessage(reset.error);
18
+ return (
19
+ <form className="form" onSubmit={handleSubmit}>
20
+ <label className="field">
21
+ New password
22
+ <input
23
+ type="password"
24
+ autoComplete="new-password"
25
+ minLength={12}
26
+ required
27
+ value={password}
28
+ onChange={(e) => setPassword(e.target.value)}
29
+ />
30
+ </label>
31
+ {message && (
32
+ <p className="form-error" role="alert">
33
+ {message}
34
+ </p>
35
+ )}
36
+ <div className="actions">
37
+ <button className="button" type="submit" disabled={reset.isPending}>
38
+ Save password
39
+ </button>
40
+ </div>
41
+ </form>
42
+ );
43
+ }
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useEffect, useRef } from "react";
5
+ import { errorMessage, useVerifyEmail } from "@/lib/auth";
6
+
7
+ // Confirms on load. Links in emails are opened with GET, so the page posts
8
+ // the token — a mail scanner prefetching the link can't use it up.
9
+ export function VerifyEmail({ token }: { token: string }) {
10
+ const verify = useVerifyEmail();
11
+ const started = useRef(false);
12
+
13
+ useEffect(() => {
14
+ if (started.current) return;
15
+ started.current = true;
16
+ verify.mutate(token);
17
+ }, [token, verify]);
18
+
19
+ if (verify.isSuccess) {
20
+ return (
21
+ <p>
22
+ Your email address is confirmed. <Link href="/account">Continue</Link>
23
+ </p>
24
+ );
25
+ }
26
+ if (verify.isError) return <p className="form-error">{errorMessage(verify.error)}</p>;
27
+ return <p className="muted">Confirming…</p>;
28
+ }
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ // Authentication hooks (generated by `gemstack add auth`). The session lives
4
+ // in an HttpOnly cookie the browser sends automatically — JavaScript never
5
+ // sees it, so there is no token to store or leak.
6
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
7
+ import { ApiError } from "@/lib/gemstack/client";
8
+ import { apiTokens, emailVerifications, passwordResets, registrations, sessions } from "@/lib/api/generated";
9
+ import type { SessionCreateInput, User } from "@/lib/api/generated";
10
+
11
+ export const authKeys = { me: ["auth", "me"] as const, tokens: ["auth", "tokens"] as const };
12
+
13
+ export type Credentials = SessionCreateInput;
14
+
15
+ /** The signed-in user, or null when signed out. */
16
+ export function useCurrentUser() {
17
+ return useQuery({
18
+ queryKey: authKeys.me,
19
+ queryFn: async () => {
20
+ try {
21
+ return await sessions.get();
22
+ } catch (error) {
23
+ if (error instanceof ApiError && error.status === 401) return null;
24
+ throw error;
25
+ }
26
+ },
27
+ staleTime: 60_000,
28
+ });
29
+ }
30
+
31
+ function useSignedIn() {
32
+ const queryClient = useQueryClient();
33
+ return (user: User) => {
34
+ queryClient.setQueryData(authKeys.me, user);
35
+ // Anything cached belonged to the previous (or no) user.
36
+ return queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] !== "auth" });
37
+ };
38
+ }
39
+
40
+ export function useLogin() {
41
+ const signedIn = useSignedIn();
42
+ return useMutation({ mutationFn: (input: Credentials) => sessions.create(input), onSuccess: signedIn });
43
+ }
44
+
45
+ export function useSignup() {
46
+ const signedIn = useSignedIn();
47
+ return useMutation({ mutationFn: (input: Credentials) => registrations.create(input), onSuccess: signedIn });
48
+ }
49
+
50
+ export function useLogout() {
51
+ const queryClient = useQueryClient();
52
+ return useMutation({
53
+ mutationFn: () => sessions.delete(),
54
+ onSuccess: () => {
55
+ queryClient.clear();
56
+ queryClient.setQueryData(authKeys.me, null);
57
+ },
58
+ });
59
+ }
60
+
61
+ export function useForgotPassword() {
62
+ return useMutation({ mutationFn: (email: string) => passwordResets.create({ email }) });
63
+ }
64
+
65
+ export function useResetPassword() {
66
+ const signedIn = useSignedIn();
67
+ return useMutation({
68
+ mutationFn: (input: { token: string; password: string }) => passwordResets.update(input),
69
+ onSuccess: signedIn,
70
+ });
71
+ }
72
+
73
+ export function useVerifyEmail() {
74
+ const queryClient = useQueryClient();
75
+ return useMutation({
76
+ mutationFn: (token: string) => emailVerifications.update({ token }),
77
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: authKeys.me }),
78
+ });
79
+ }
80
+
81
+ export function useResendVerification() {
82
+ return useMutation({ mutationFn: () => emailVerifications.create() });
83
+ }
84
+
85
+ export function useApiTokens() {
86
+ return useQuery({ queryKey: authKeys.tokens, queryFn: () => apiTokens.list() });
87
+ }
88
+
89
+ export function useCreateApiToken() {
90
+ const queryClient = useQueryClient();
91
+ return useMutation({
92
+ mutationFn: (name: string) => apiTokens.create({ name }),
93
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: authKeys.tokens }),
94
+ });
95
+ }
96
+
97
+ export function useRevokeApiToken() {
98
+ const queryClient = useQueryClient();
99
+ return useMutation({
100
+ mutationFn: (id: number) => apiTokens.delete(id),
101
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: authKeys.tokens }),
102
+ });
103
+ }
104
+
105
+ /** A readable message for a failed auth request. */
106
+ export function errorMessage(error: unknown): string | null {
107
+ if (!error) return null;
108
+ if (error instanceof ApiError) {
109
+ const fields = Object.entries(error.errors).map(([field, messages]) => `${field} ${messages.join(", ")}`);
110
+ return fields.length ? fields.join(". ") : error.message;
111
+ }
112
+ return "Something went wrong. Please try again.";
113
+ }
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ # The authentication flows generated by `gemstack add auth`.
6
+ class AuthTest < GemStack::TestCase
7
+ PASSWORD = "correct horse battery staple"
8
+
9
+ def create_user(email = "ada@example.com") = User.create(email: email, password: PASSWORD)
10
+ def token_from(mail) = mail.text_part.decoded[/token=([\w-]+)/, 1]
11
+
12
+ def test_signup_signs_in_and_sends_a_verification_email
13
+ assert_emails(1) { post_json "/api/auth/signup", { email: "Ada@Example.com", password: PASSWORD } }
14
+
15
+ assert_status 201
16
+ assert_equal "ada@example.com", json_body["email"]
17
+ refute json_body.key?("password_digest")
18
+ assert_equal ["ada@example.com"], last_email.to
19
+ get_json "/api/auth/me"
20
+
21
+ assert_status 200
22
+ end
23
+
24
+ def test_signup_validation
25
+ create_user
26
+ post_json "/api/auth/signup", { email: "ada@example.com", password: "short" }
27
+
28
+ assert_error 422, "validation_failed"
29
+ assert_equal ["is already taken"], json_body["errors"]["email"]
30
+ assert_equal ["is too short (minimum 12 characters)"], json_body["errors"]["password"]
31
+ end
32
+
33
+ def test_login_me_logout
34
+ user = create_user
35
+ post_json "/api/auth/login", { email: "ADA@example.com", password: PASSWORD }
36
+
37
+ assert_status 200
38
+ get_json "/api/auth/me"
39
+
40
+ assert_equal user.id, json_body["id"]
41
+ delete_json "/api/auth/logout"
42
+
43
+ assert_status 204
44
+ get_json "/api/auth/me"
45
+
46
+ assert_error 401, "unauthenticated"
47
+ end
48
+
49
+ def test_login_failures_look_the_same
50
+ create_user
51
+ post_json "/api/auth/login", { email: "ada@example.com", password: "wrong password!" }
52
+ wrong_password = json_body
53
+ post_json "/api/auth/login", { email: "nobody@example.com", password: PASSWORD }
54
+
55
+ assert_error 401, "invalid_credentials"
56
+ assert_equal wrong_password["error"]["message"], json_body["error"]["message"]
57
+ end
58
+
59
+ def test_password_reset
60
+ user = create_user
61
+ other_device = GemStack::Auth::Sessions.create(user.id)
62
+ assert_emails(1) { post_json "/api/auth/password/forgot", { email: user.email } }
63
+
64
+ assert_status 202
65
+ post_json "/api/auth/password/reset", { token: token_from(last_email), password: "a brand new passphrase" }
66
+
67
+ assert_status 200
68
+ assert User.authenticate_by(email: user.email, password: "a brand new passphrase")
69
+ assert_nil GemStack::Auth::Sessions.find(other_device), "other sessions end"
70
+ post_json "/api/auth/password/reset", { token: token_from(last_email), password: "and another one!!" }
71
+
72
+ assert_error 400, "invalid_token"
73
+ end
74
+
75
+ def test_forgot_password_does_not_reveal_accounts
76
+ assert_emails(0) { post_json "/api/auth/password/forgot", { email: "nobody@example.com" } }
77
+
78
+ assert_status 202
79
+ end
80
+
81
+ def test_email_verification
82
+ user = create_user
83
+ sign_in_as(user)
84
+ assert_emails(1) { post_json "/api/auth/email/resend" }
85
+ post_json "/api/auth/email/verify", { token: token_from(last_email) }
86
+
87
+ assert_status 204
88
+ assert User[user.id].email_verified?
89
+ end
90
+
91
+ def test_api_tokens
92
+ user = create_user
93
+ sign_in_as(user)
94
+ post_json "/api/auth/tokens", { name: "CI" }
95
+
96
+ assert_status 201
97
+ token = json_body["token"]
98
+ get_json "/api/auth/me", {}, { "HTTP_AUTHORIZATION" => "Bearer #{token}" }
99
+
100
+ assert_equal user.id, json_body["id"]
101
+ get_json "/api/auth/tokens"
102
+
103
+ assert_equal ["CI"], json_body.map { |t| t["name"] }
104
+ delete_json "/api/auth/tokens/#{json_body.first["id"]}"
105
+
106
+ assert_status 204
107
+ end
108
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < ApplicationController
4
+ <% actions.each_with_index do |action, index| -%>
5
+ <%= "\n" if index.positive? %> # <%= route_for(action)[0].upcase %> /api<%= route_for(action)[1] %>
6
+ def <%= action %>
7
+ render({ action: "<%= file_name %>#<%= action %>" })
8
+ end
9
+ <% end -%>
10
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "<%= "../" * (file_name.count("/") + 1) %>test_helper"
4
+
5
+ class <%= class_name %>Test < GemStack::TestCase
6
+ <% actions.each_with_index do |action, index| -%>
7
+ <%= "\n" if index.positive? %> def test_<%= action %>
8
+ <%= route_for(action)[0] %>_json "<%= request_path(action) %>"
9
+
10
+ assert_status 200
11
+ assert_equal({ "action" => "<%= file_name %>#<%= action %>" }, json_body)
12
+ end
13
+ <% end -%>
14
+ end
@@ -0,0 +1,19 @@
1
+ # One public origin: /api/* → Ruby, everything else → Next.js (docs/deployment.md).
2
+ # SITE_ADDRESS=example.com gets a Let's Encrypt certificate automatically;
3
+ # "localhost" uses Caddy's local CA (trust it with `caddy trust`, or curl -k).
4
+ {$SITE_ADDRESS:localhost} {
5
+ encode zstd gzip
6
+
7
+ handle /api/* {
8
+ reverse_proxy api:4000 {
9
+ # Server-Sent Events: stream responses immediately.
10
+ flush_interval -1
11
+ }
12
+ }
13
+ <% if frontend? -%>
14
+
15
+ handle {
16
+ reverse_proxy web:3000
17
+ }
18
+ <% end -%>
19
+ }
@@ -0,0 +1,62 @@
1
+ # syntax=docker/dockerfile:1
2
+ # Production images for <%= app_name %> (gemstack generate deploy — docs/deployment.md).
3
+ #
4
+ # docker build --target api -t <%= app_name %>-api . # Ruby API (Puma), also runs jobs and migrations
5
+ <% if frontend? -%>
6
+ # docker build --target web -t <%= app_name %>-web . # Next.js
7
+ <% end -%>
8
+ #
9
+ # Configuration comes from the environment at runtime (SECRET_KEY_BASE,
10
+ # DATABASE_URL, …); nothing secret is baked into the images.
11
+
12
+ ARG RUBY_VERSION=<%= ruby_version %>
13
+ ARG NODE_VERSION=<%= node_version %>
14
+
15
+ # ── Ruby API ────────────────────────────────────────────────────────────────
16
+ FROM ruby:${RUBY_VERSION}-slim AS api-build
17
+ RUN apt-get update -qq && apt-get install --no-install-recommends -y build-essential libpq-dev libyaml-dev git \
18
+ && rm -rf /var/lib/apt/lists/*
19
+ WORKDIR /app
20
+ ENV BUNDLE_DEPLOYMENT=1 BUNDLE_WITHOUT="development:test" BUNDLE_PATH=/usr/local/bundle
21
+ COPY Gemfile Gemfile.lock ./
22
+ COPY vendor/ ./vendor/
23
+ RUN bundle install --jobs 4 && rm -rf /usr/local/bundle/cache "${BUNDLE_PATH}"/ruby/*/cache
24
+ COPY . .
25
+ RUN rm -rf frontend tmp/* log/* .env*
26
+
27
+ FROM ruby:${RUBY_VERSION}-slim AS api
28
+ RUN apt-get update -qq && apt-get install --no-install-recommends -y libpq5 libyaml-0-2 curl \
29
+ && rm -rf /var/lib/apt/lists/* \
30
+ && groupadd --system --gid 1000 app && useradd --system --uid 1000 --gid app --create-home app
31
+ WORKDIR /app
32
+ ENV GEMSTACK_ENV=production BUNDLE_DEPLOYMENT=1 BUNDLE_WITHOUT="development:test" BUNDLE_PATH=/usr/local/bundle \
33
+ GEMSTACK_API_HOST=0.0.0.0 GEMSTACK_API_PORT=4000
34
+ COPY --from=api-build /usr/local/bundle /usr/local/bundle
35
+ COPY --from=api-build --chown=app:app /app /app
36
+ USER app
37
+ EXPOSE 4000
38
+ HEALTHCHECK --interval=10s --timeout=3s --start-period=20s CMD curl -fsS http://127.0.0.1:4000/api/health || exit 1
39
+ # Other processes use the same image: `bundle exec gemstack jobs`, `bundle exec gemstack db:migrate`.
40
+ CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
41
+ <% if frontend? -%>
42
+
43
+ # ── Next.js ─────────────────────────────────────────────────────────────────
44
+ FROM node:${NODE_VERSION}-slim AS web-build
45
+ WORKDIR /app
46
+ COPY frontend/package.json frontend/package-lock.json ./
47
+ RUN npm ci
48
+ COPY frontend/ ./
49
+ ENV NEXT_TELEMETRY_DISABLED=1
50
+ # GEMSTACK_API_URL is only needed here when Next.js proxies /api itself (no reverse proxy in front).
51
+ ARG GEMSTACK_API_URL
52
+ ENV GEMSTACK_API_URL=${GEMSTACK_API_URL}
53
+ RUN npm run build && npm prune --omit=dev
54
+
55
+ FROM node:${NODE_VERSION}-slim AS web
56
+ WORKDIR /app
57
+ ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000
58
+ COPY --from=web-build --chown=node:node /app ./
59
+ USER node
60
+ EXPOSE 3000
61
+ CMD ["npm", "start"]
62
+ <% end -%>
@@ -0,0 +1,6 @@
1
+ # Process types for Procfile platforms (Heroku, Render, Railway, Dokku…) — docs/deployment.md.
2
+ release: bundle exec gemstack db:migrate
3
+ web: GEMSTACK_API_PORT=${PORT:-4000} bundle exec puma -C config/puma.rb
4
+ <% if jobs? -%>
5
+ worker: bundle exec gemstack jobs
6
+ <% end -%>
@@ -0,0 +1,80 @@
1
+ # Runs <%= app_name %> in production mode on this machine, behind Caddy on https://localhost:
2
+ #
3
+ # export SECRET_KEY_BASE=$(openssl rand -hex 64) POSTGRES_PASSWORD=$(openssl rand -hex 16)
4
+ # docker compose up --build
5
+ #
6
+ # It's also a map of what a real deploy needs: one image per process type,
7
+ # migrations before the API starts, and a proxy sending /api to Ruby.
8
+ name: <%= app_name.tr("_", "-") %>
9
+
10
+ x-api-env: &api-env
11
+ GEMSTACK_ENV: production
12
+ SECRET_KEY_BASE: ${SECRET_KEY_BASE:?set SECRET_KEY_BASE (openssl rand -hex 64)}
13
+ DATABASE_URL: postgres://app:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/app
14
+ APP_URL: ${APP_URL:-https://localhost}
15
+ <% if mail? -%>
16
+ SMTP_URL: ${SMTP_URL:-} # e.g. smtp://user:pass@smtp.example.com:587 — emails retry until it is set
17
+ MAIL_FROM: ${MAIL_FROM:-}
18
+ <% end -%>
19
+
20
+ services:
21
+ db:
22
+ image: postgres:17
23
+ environment:
24
+ POSTGRES_USER: app
25
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
26
+ POSTGRES_DB: app
27
+ volumes: [db:/var/lib/postgresql/data]
28
+ healthcheck:
29
+ test: ["CMD-SHELL", "pg_isready -U app -d app"]
30
+ interval: 2s
31
+ retries: 30
32
+
33
+ migrate:
34
+ image: <%= app_name %>-api
35
+ build: { context: ., target: api }
36
+ healthcheck: { disable: true }
37
+ command: ["bundle", "exec", "gemstack", "db:migrate"]
38
+ environment: *api-env
39
+ depends_on: { db: { condition: service_healthy } }
40
+
41
+ api:
42
+ image: <%= app_name %>-api
43
+ build: { context: ., target: api }
44
+ environment:
45
+ <<: *api-env
46
+ WEB_CONCURRENCY: ${WEB_CONCURRENCY:-2}
47
+ depends_on: { migrate: { condition: service_completed_successfully } }
48
+ <% if jobs? -%>
49
+
50
+ jobs:
51
+ image: <%= app_name %>-api
52
+ build: { context: ., target: api }
53
+ healthcheck: { disable: true }
54
+ command: ["bundle", "exec", "gemstack", "jobs"]
55
+ environment: *api-env
56
+ depends_on: { migrate: { condition: service_completed_successfully } }
57
+ <% end -%>
58
+ <% if frontend? -%>
59
+
60
+ web:
61
+ image: <%= app_name %>-web
62
+ build: { context: ., target: web }
63
+ environment:
64
+ GEMSTACK_API_URL: http://api:4000 # Server Components call the API directly
65
+ depends_on: [api]
66
+ <% end -%>
67
+
68
+ proxy:
69
+ image: caddy:2
70
+ ports: ["80:80", "443:443"]
71
+ environment:
72
+ SITE_ADDRESS: ${SITE_ADDRESS:-localhost}
73
+ volumes:
74
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
75
+ - caddy:/data
76
+ depends_on: [api<%= ", web" if frontend? %>]
77
+
78
+ volumes:
79
+ db:
80
+ caddy: