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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +24 -0
- data/exe/gemstack +6 -0
- data/lib/gemstack/cli/add_generator.rb +237 -0
- data/lib/gemstack/cli/app_generator.rb +110 -0
- data/lib/gemstack/cli/commands/db.rb +134 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +64 -0
- data/lib/gemstack/cli/doctor.rb +277 -0
- data/lib/gemstack/cli/generator.rb +122 -0
- data/lib/gemstack/cli/job_generator.rb +47 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +169 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +299 -0
- data/templates/app/Gemfile.tt +39 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +30 -0
- data/templates/app/config/environments/development.rb +6 -0
- data/templates/app/config/environments/production.rb +8 -0
- data/templates/app/config/environments/test.rb +7 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example +9 -0
- data/templates/app/dot_gitignore +14 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +1 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +62 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +80 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.tsx.tt +45 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +63 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- metadata +195 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #ffffff;
|
|
3
|
+
--fg: #16181d;
|
|
4
|
+
--muted: #5b6170;
|
|
5
|
+
--code-bg: #f1f2f5;
|
|
6
|
+
--ok: #1f9d55;
|
|
7
|
+
--error: #d64545;
|
|
8
|
+
--pending: #d99a1e;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@media (prefers-color-scheme: dark) {
|
|
12
|
+
:root {
|
|
13
|
+
--bg: #0f1115;
|
|
14
|
+
--fg: #e6e8ee;
|
|
15
|
+
--muted: #9aa0ad;
|
|
16
|
+
--code-bg: #1c2030;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
* {
|
|
21
|
+
box-sizing: border-box;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
body {
|
|
25
|
+
margin: 0;
|
|
26
|
+
background: var(--bg);
|
|
27
|
+
color: var(--fg);
|
|
28
|
+
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
code {
|
|
32
|
+
background: var(--code-bg);
|
|
33
|
+
padding: 0.1rem 0.35rem;
|
|
34
|
+
border-radius: 4px;
|
|
35
|
+
font-size: 0.9em;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.page {
|
|
39
|
+
max-width: 40rem;
|
|
40
|
+
margin: 0 auto;
|
|
41
|
+
padding: 4rem 1rem;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.muted {
|
|
45
|
+
color: var(--muted);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.status {
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: 0.6rem;
|
|
52
|
+
padding: 0.75rem 1rem;
|
|
53
|
+
border-radius: 8px;
|
|
54
|
+
background: var(--code-bg);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.dot {
|
|
58
|
+
width: 0.6rem;
|
|
59
|
+
height: 0.6rem;
|
|
60
|
+
border-radius: 50%;
|
|
61
|
+
flex: none;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.status-ok .dot {
|
|
65
|
+
background: var(--ok);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.status-error .dot {
|
|
69
|
+
background: var(--error);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.status-pending .dot {
|
|
73
|
+
background: var(--pending);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
a {
|
|
77
|
+
color: inherit;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
h1 {
|
|
81
|
+
margin-top: 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.page-header,
|
|
85
|
+
.actions {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
justify-content: space-between;
|
|
89
|
+
gap: 1rem;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.actions {
|
|
93
|
+
justify-content: flex-start;
|
|
94
|
+
margin-top: 1.5rem;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.button {
|
|
98
|
+
display: inline-block;
|
|
99
|
+
padding: 0.45rem 0.9rem;
|
|
100
|
+
border: 1px solid var(--fg);
|
|
101
|
+
border-radius: 6px;
|
|
102
|
+
background: var(--fg);
|
|
103
|
+
color: var(--bg);
|
|
104
|
+
font: inherit;
|
|
105
|
+
text-decoration: none;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.button:disabled {
|
|
110
|
+
opacity: 0.6;
|
|
111
|
+
cursor: default;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.button-danger {
|
|
115
|
+
background: var(--error);
|
|
116
|
+
border-color: var(--error);
|
|
117
|
+
color: #fff;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.table {
|
|
121
|
+
width: 100%;
|
|
122
|
+
border-collapse: collapse;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.table th,
|
|
126
|
+
.table td {
|
|
127
|
+
padding: 0.5rem;
|
|
128
|
+
border-bottom: 1px solid var(--code-bg);
|
|
129
|
+
text-align: left;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.form {
|
|
133
|
+
display: grid;
|
|
134
|
+
gap: 1rem;
|
|
135
|
+
max-width: 32rem;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.field {
|
|
139
|
+
display: grid;
|
|
140
|
+
gap: 0.3rem;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.field input:not([type="checkbox"]),
|
|
144
|
+
.field textarea {
|
|
145
|
+
padding: 0.45rem;
|
|
146
|
+
border: 1px solid var(--muted);
|
|
147
|
+
border-radius: 6px;
|
|
148
|
+
background: var(--bg);
|
|
149
|
+
color: var(--fg);
|
|
150
|
+
font: inherit;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.field-error,
|
|
154
|
+
.form-error {
|
|
155
|
+
margin: 0;
|
|
156
|
+
color: var(--error);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.card {
|
|
160
|
+
display: grid;
|
|
161
|
+
grid-template-columns: max-content 1fr;
|
|
162
|
+
gap: 0.4rem 1.5rem;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.card dt {
|
|
166
|
+
color: var(--muted);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.card dd {
|
|
170
|
+
margin: 0;
|
|
171
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Providers } from "./providers";
|
|
4
|
+
import "./globals.css";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: "<%= module_name %>",
|
|
8
|
+
description: "A GemStack application",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<body>
|
|
15
|
+
<Providers>{children}</Providers>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { api, API_PATH } from "@/lib/gemstack/client";
|
|
5
|
+
|
|
6
|
+
// Starter page: confirms the browser reaches the Ruby API through the same origin.
|
|
7
|
+
// Replace it with your application.
|
|
8
|
+
export default function Home() {
|
|
9
|
+
const health = useQuery({
|
|
10
|
+
queryKey: ["health"],
|
|
11
|
+
queryFn: () => api.get<{ status: string }>("/health"),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const state = health.isPending ? "pending" : health.isError ? "error" : "ok";
|
|
15
|
+
const label = {
|
|
16
|
+
pending: "Connecting to the Ruby API…",
|
|
17
|
+
error: `Ruby API unreachable: ${health.error?.message}`,
|
|
18
|
+
ok: `Ruby API connected — GET ${API_PATH}/health → ${health.data?.status}`,
|
|
19
|
+
}[state];
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<main className="page">
|
|
23
|
+
<h1><%= module_name %></h1>
|
|
24
|
+
<p className="muted">Next.js + TypeScript frontend, GemStack Ruby API, one origin.</p>
|
|
25
|
+
|
|
26
|
+
<p className={`status status-${state}`} role="status">
|
|
27
|
+
<span className="dot" aria-hidden="true" />
|
|
28
|
+
{label}
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
<h2>Next steps</h2>
|
|
32
|
+
<ul>
|
|
33
|
+
<li>
|
|
34
|
+
Add an endpoint: <code>gemstack generate controller Status show</code>
|
|
35
|
+
</li>
|
|
36
|
+
<li>
|
|
37
|
+
Call it from here: <code>api.get("/status/1")</code> (see <code>lib/gemstack/client.ts</code>)
|
|
38
|
+
</li>
|
|
39
|
+
<li>
|
|
40
|
+
Edit this page: <code>frontend/app/page.tsx</code>
|
|
41
|
+
</li>
|
|
42
|
+
</ul>
|
|
43
|
+
</main>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { useState, type ReactNode } from "react";
|
|
5
|
+
import { ApiError } from "@/lib/gemstack/client";
|
|
6
|
+
|
|
7
|
+
// Server state lives in TanStack Query; local UI state is plain React state;
|
|
8
|
+
// URL state uses Next.js search params. No global store is needed by default.
|
|
9
|
+
export function Providers({ children }: { children: ReactNode }) {
|
|
10
|
+
const [queryClient] = useState(
|
|
11
|
+
() =>
|
|
12
|
+
new QueryClient({
|
|
13
|
+
defaultOptions: {
|
|
14
|
+
queries: {
|
|
15
|
+
staleTime: 30_000,
|
|
16
|
+
// Don't retry requests the API rejected (4xx); retry transient failures twice.
|
|
17
|
+
retry: (failureCount, error) => !(error instanceof ApiError && error.isClientError) && failureCount < 2,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GemStack API client runtime.
|
|
3
|
+
*
|
|
4
|
+
* This file belongs to your application (it is not a hidden dependency):
|
|
5
|
+
* add auth headers, interceptors or logging here as needed.
|
|
6
|
+
*
|
|
7
|
+
* Where requests go:
|
|
8
|
+
* - Browser: relative URLs ("/api/..."), i.e. the same origin — no CORS, no config.
|
|
9
|
+
* - Server Components / Route Handlers: GEMSTACK_API_URL (injected by `gemstack dev`,
|
|
10
|
+
* set by you in production) so the Next.js server calls Ruby directly.
|
|
11
|
+
* - API on another domain: set NEXT_PUBLIC_GEMSTACK_API_URL.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const API_PATH = process.env.NEXT_PUBLIC_GEMSTACK_API_PATH ?? "/api";
|
|
15
|
+
|
|
16
|
+
/** The JSON error envelope every GemStack API error uses. */
|
|
17
|
+
export interface ApiErrorBody {
|
|
18
|
+
error: { code: string; message: string; request_id?: string };
|
|
19
|
+
/** Field-level validation messages, e.g. { name: ["is required"] }. */
|
|
20
|
+
errors?: Record<string, string[]>;
|
|
21
|
+
/** Development only (config.http.show_exceptions): the server-side exception. */
|
|
22
|
+
exception?: { class: string; message: string; backtrace: string[] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ApiError extends Error {
|
|
26
|
+
readonly status: number;
|
|
27
|
+
readonly code: string;
|
|
28
|
+
readonly requestId?: string;
|
|
29
|
+
readonly errors: Record<string, string[]>;
|
|
30
|
+
|
|
31
|
+
constructor(status: number, body: ApiErrorBody | null, fallbackMessage: string) {
|
|
32
|
+
super(body?.error?.message ?? fallbackMessage);
|
|
33
|
+
this.name = "ApiError";
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.code = body?.error?.code ?? "http_error";
|
|
36
|
+
this.requestId = body?.error?.request_id;
|
|
37
|
+
this.errors = body?.errors ?? {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True for 4xx responses: retrying the same request won't help. */
|
|
41
|
+
get isClientError(): boolean {
|
|
42
|
+
return this.status >= 400 && this.status < 500;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type QueryValue = string | number | boolean | null | undefined;
|
|
47
|
+
export type Query = Record<string, QueryValue | QueryValue[]>;
|
|
48
|
+
|
|
49
|
+
export interface RequestOptions extends Omit<RequestInit, "body" | "method"> {
|
|
50
|
+
query?: Query;
|
|
51
|
+
body?: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function baseUrl(): string {
|
|
55
|
+
const publicUrl = process.env.NEXT_PUBLIC_GEMSTACK_API_URL;
|
|
56
|
+
if (publicUrl) return publicUrl.replace(/\/$/, "");
|
|
57
|
+
if (typeof window === "undefined") {
|
|
58
|
+
return (process.env.GEMSTACK_API_URL ?? "http://127.0.0.1:4000").replace(/\/$/, "");
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Builds a URL for an API path: apiUrl("/products", { page: 2 }) → "/api/products?page=2". */
|
|
64
|
+
export function apiUrl(path: string, query?: Query): string {
|
|
65
|
+
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
66
|
+
let url = `${baseUrl()}${API_PATH}${normalized}`;
|
|
67
|
+
if (query) {
|
|
68
|
+
const params = new URLSearchParams();
|
|
69
|
+
for (const [key, value] of Object.entries(query)) {
|
|
70
|
+
const values = Array.isArray(value) ? value : [value];
|
|
71
|
+
for (const item of values) {
|
|
72
|
+
if (item !== undefined && item !== null) params.append(Array.isArray(value) ? `${key}[]` : key, String(item));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const qs = params.toString();
|
|
76
|
+
if (qs) url += `?${qs}`;
|
|
77
|
+
}
|
|
78
|
+
return url;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parse(text: string): unknown {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function request<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
|
|
90
|
+
const { query, body, headers, ...init } = options;
|
|
91
|
+
const requestHeaders = new Headers(headers);
|
|
92
|
+
requestHeaders.set("accept", "application/json");
|
|
93
|
+
|
|
94
|
+
let payload: BodyInit | undefined;
|
|
95
|
+
if (body !== undefined) {
|
|
96
|
+
if (body instanceof FormData || body instanceof Blob || body instanceof URLSearchParams) {
|
|
97
|
+
payload = body;
|
|
98
|
+
} else {
|
|
99
|
+
payload = JSON.stringify(body);
|
|
100
|
+
requestHeaders.set("content-type", "application/json");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const response = await fetch(apiUrl(path, query), { ...init, method, headers: requestHeaders, body: payload });
|
|
105
|
+
const text = response.status === 204 ? "" : await response.text();
|
|
106
|
+
const data = text ? parse(text) : null;
|
|
107
|
+
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
const exception = (data as ApiErrorBody | null)?.exception;
|
|
110
|
+
if (exception && process.env.NODE_ENV !== "production") {
|
|
111
|
+
// The Ruby exception behind a 500, so it shows up next to the failing request.
|
|
112
|
+
console.error(`[GemStack API] ${method} ${path} → ${exception.class}: ${exception.message}\n ${exception.backtrace.slice(0, 8).join("\n ")}`);
|
|
113
|
+
}
|
|
114
|
+
throw new ApiError(response.status, data as ApiErrorBody | null, response.statusText || `HTTP ${response.status}`);
|
|
115
|
+
}
|
|
116
|
+
return data as T;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const api = {
|
|
120
|
+
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, options),
|
|
121
|
+
post: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("POST", path, { ...options, body }),
|
|
122
|
+
put: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PUT", path, { ...options, body }),
|
|
123
|
+
patch: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PATCH", path, { ...options, body }),
|
|
124
|
+
delete: <T = void>(path: string, options?: RequestOptions) => request<T>("DELETE", path, options),
|
|
125
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
// One public origin, in every environment:
|
|
4
|
+
// - `gemstack dev`: the GemStack gateway routes /api/* to Ruby before requests reach Next.js.
|
|
5
|
+
// - Production: set GEMSTACK_API_URL (e.g. http://api.internal:4000) and Next.js proxies
|
|
6
|
+
// /api/* to the Ruby API. Leave it unset if a reverse proxy already routes /api/*.
|
|
7
|
+
const apiUrl = process.env.GEMSTACK_API_URL?.replace(/\/$/, "");
|
|
8
|
+
const apiPath = process.env.NEXT_PUBLIC_GEMSTACK_API_PATH ?? "/api";
|
|
9
|
+
|
|
10
|
+
const nextConfig: NextConfig = {
|
|
11
|
+
async rewrites() {
|
|
12
|
+
if (!apiUrl) return [];
|
|
13
|
+
return [{ source: `${apiPath}/:path*`, destination: `${apiUrl}${apiPath}/:path*` }];
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default nextConfig;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "<%= name %>-frontend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"typecheck": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@tanstack/react-query": "^5.104.0",
|
|
13
|
+
"next": "^16.3.6",
|
|
14
|
+
"react": "^19.3.0",
|
|
15
|
+
"react-dom": "^19.3.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"@types/react": "^19.3.0",
|
|
20
|
+
"@types/react-dom": "^19.3.0",
|
|
21
|
+
"typescript": "~5.9.3"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": false,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [{ "name": "next" }],
|
|
17
|
+
"paths": { "@/*": ["./*"] }
|
|
18
|
+
},
|
|
19
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
|
20
|
+
"exclude": ["node_modules"]
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Enqueue with <%= class_name %>.perform_later(...) — arguments must be JSON values (pass ids, not records).
|
|
4
|
+
# Runs in `gemstack jobs` (and in `gemstack dev`). Delivery is at-least-once: keep perform idempotent.
|
|
5
|
+
class <%= class_name %> < GemStack::Job
|
|
6
|
+
<% if queue_name -%>
|
|
7
|
+
queue :<%= queue_name %>
|
|
8
|
+
<% end -%>
|
|
9
|
+
# retry_on SomeTimeoutError, attempts: 5 # default: any StandardError, 10 attempts, exponential backoff
|
|
10
|
+
# discard_on GemStack::NotFound # drop instead of retrying
|
|
11
|
+
|
|
12
|
+
def perform(*args)
|
|
13
|
+
GemStack.logger.info("<%= class_name %> ran", args: args)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
class <%= class_name %>Test < GemStack::TestCase
|
|
6
|
+
def test_enqueue
|
|
7
|
+
<%= class_name %>.perform_later(1)
|
|
8
|
+
|
|
9
|
+
assert_enqueued <%= class_name %>, args: [1]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_perform
|
|
13
|
+
perform_enqueued_jobs { <%= class_name %>.perform_later(1) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Sequel migration — see https://sequel.jeremyevans.net/rdoc/files/doc/schema_modification_rdoc.html
|
|
4
|
+
Sequel.migration do
|
|
5
|
+
change do
|
|
6
|
+
<% if table && !columns.empty? -%>
|
|
7
|
+
alter_table(:<%= table %>) do
|
|
8
|
+
<% columns.each do |column| -%>
|
|
9
|
+
<%= column %>
|
|
10
|
+
<% end -%>
|
|
11
|
+
end
|
|
12
|
+
<% else -%>
|
|
13
|
+
# create_table(:things) { primary_key :id, type: :Bignum }
|
|
14
|
+
# alter_table(:things) { add_column :name, String }
|
|
15
|
+
<% end -%>
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Who may do what with <%= model_name %> records (docs/authorization.md).
|
|
4
|
+
# `user` is the signed-in user (nil when anonymous); `record` the <%= model_name %>.
|
|
5
|
+
# In controllers: authorize!(record) checks "<action>?", policy_scope(<%= model_name %>) filters lists.
|
|
6
|
+
class <%= class_name %> < <%= parent_class %>
|
|
7
|
+
def index? = !user.nil?
|
|
8
|
+
def show? = owner?
|
|
9
|
+
def create? = !user.nil?
|
|
10
|
+
def update? = owner?
|
|
11
|
+
def destroy? = owner?
|
|
12
|
+
|
|
13
|
+
class Scope < Scope
|
|
14
|
+
# The <%= model_name %> records the user may list. Adjust to your data model.
|
|
15
|
+
def resolve = user ? scope.where(user_id: user.id) : scope.where(false)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def owner? = !user.nil? && record.respond_to?(:user_id) && record.user_id == user.id
|
|
21
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Realtime channels browsers may subscribe to (docs/realtime.md). Anything not
|
|
4
|
+
# listed here is refused. `*` matches one segment and is passed to the block,
|
|
5
|
+
# together with the request (cookies, headers) for authorization.
|
|
6
|
+
#
|
|
7
|
+
# GemStack.broadcast("orders:#{order.id}", "order.updated", order)
|
|
8
|
+
#
|
|
9
|
+
GemStack.channels do
|
|
10
|
+
# channel "announcements" # public
|
|
11
|
+
# channel "orders:*" do |order_id, request| # private
|
|
12
|
+
# Order.find_by(id: order_id)&.user_id == current_user_id(request)
|
|
13
|
+
# end
|
|
14
|
+
end
|