ascua-scaffold 0.2.0 → 0.3.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 (4) hide show
  1. package/README.md +26 -0
  2. package/generate.js +124 -0
  3. package/index.js +179 -88
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -83,6 +83,31 @@ un `<form>` de HTML, así que también funciona sin JavaScript.
83
83
  - Un `UNIQUE` repetido vuelve con un 409 y el error bajo la celda de esa
84
84
  fila.
85
85
 
86
+ ## El admin
87
+
88
+ ```sh
89
+ stil exec ascua-scaffold --admin
90
+ ```
91
+
92
+ Genera todas las tablas a la vez bajo `/admin`, como el admin de Django:
93
+ lista, alta, edición y grilla de cada una, un panel en `/admin` y una barra
94
+ con las tablas.
95
+
96
+ - **Pide usuario y contraseña** (HTTP Basic): `ADMIN_PASSWORD` y
97
+ `ADMIN_USER` («admin» por defecto). **Sin `ADMIN_PASSWORD`, `/admin` es un
98
+ 404**: nunca queda abierto por olvido. Va en `src/routes/admin/_middleware.ts`,
99
+ para cambiarlo por el inicio de sesión del sitio (passkeys, una sesión)
100
+ cuando lo tenga.
101
+ - **Reutiliza lo que hay**: una tabla que ya tiene sus consultas y su
102
+ `src/resources/<tabla>.ts` —de un `ascua-scaffold <tabla>` anterior— los
103
+ conserva y solo recibe sus páginas del admin.
104
+ - **Lo que no puede generar lo salta y dice por qué**: vistas, claves
105
+ compuestas, columnas obligatorias que el formulario no sabe editar. El
106
+ panel las lista.
107
+ - **Se puede volver a correr**: tras una migración nueva, agrega las tablas
108
+ nuevas y deja como están las que ya tenían página. `--force` las reescribe
109
+ todas.
110
+
86
111
  ## Requisitos
87
112
 
88
113
  - Un sitio con servidor: `ascua({ site: { mode: "server" } })` en
@@ -97,6 +122,7 @@ un `<form>` de HTML, así que también funciona sin JavaScript.
97
122
 
98
123
  ```text
99
124
  ascua-scaffold <tabla> [--config brasa.json] [--force]
125
+ ascua-scaffold --admin [--config brasa.json] [--force]
100
126
  ```
101
127
 
102
128
  ## Licencia
package/generate.js CHANGED
@@ -765,6 +765,130 @@ export default function ${n.plural}Grid({ data, form }: { data: Awaited<ReturnTy
765
765
  `;
766
766
  }
767
767
 
768
+ /** `src/resources/admin.ts`: las tablas del admin. Se reescribe en cada `--admin`. */
769
+ export function datosAdmin(incluidas, saltadas) {
770
+ const filas = incluidas.map((x) => ` { name: ${JSON.stringify(x.name)}, label: ${JSON.stringify(etiqueta(x.name))}, path: ${JSON.stringify(x.path)} },`).join("\n");
771
+ // Los motivos vienen de la CLI, con \`nombres\` entre comillas invertidas: en la página, sin ellas.
772
+ const fuera = saltadas.map((x) => ` { name: ${JSON.stringify(x.name)}, reason: ${JSON.stringify(x.reason.replace(/\`/g, ""))} },`).join("\n");
773
+ return `// Written by \`ascua-scaffold --admin\` each time it runs: edits here are lost.
774
+
775
+ /** The tables the admin manages, in the order of the migrations. */
776
+ export const adminTables: readonly { name: string; label: string; path: string }[] = [
777
+ ${filas}
778
+ ];
779
+
780
+ /** The tables it left out, and why. */
781
+ export const skippedTables: readonly { name: string; reason: string }[] = [
782
+ ${fuera}
783
+ ];
784
+ `;
785
+ }
786
+
787
+ /** `src/routes/admin/_middleware.ts`: sin contraseña configurada, el admin no existe. */
788
+ export function guardiaAdmin() {
789
+ return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
790
+ import { notFound, type Middleware } from "vite-plugin-ascua/site";
791
+
792
+ /**
793
+ * The admin asks for a user and a password (HTTP Basic): ADMIN_USER, "admin"
794
+ * by default, and ADMIN_PASSWORD. Without ADMIN_PASSWORD the admin does not
795
+ * exist: every page under /admin is a 404. Serve it over HTTPS only, and swap
796
+ * this for the site's own sign-in (passkeys, a session) when it has one.
797
+ */
798
+ const middleware: Middleware = ({ request, setHeaders }) => {
799
+ setHeaders({ "cache-control": "no-store", "x-robots-tag": "noindex" });
800
+ const password = process.env.ADMIN_PASSWORD;
801
+ if (!password) throw notFound();
802
+ const expected = \`Basic \${base64(\`\${process.env.ADMIN_USER || "admin"}:\${password}\`)}\`;
803
+ if (!same(request.headers.get("authorization") ?? "", expected)) {
804
+ return new Response("Sign in to the admin.", {
805
+ status: 401,
806
+ headers: { "www-authenticate": 'Basic realm="admin", charset="UTF-8"', "cache-control": "no-store" },
807
+ });
808
+ }
809
+ };
810
+
811
+ export default middleware;
812
+
813
+ /** UTF-8 to base64, the same on Node and on Workers. */
814
+ function base64(text: string): string {
815
+ return btoa(String.fromCharCode(...new TextEncoder().encode(text)));
816
+ }
817
+
818
+ /** Compares without stopping at the first difference: the time it takes does not tell how much matched. */
819
+ function same(a: string, b: string): boolean {
820
+ let diff = a.length ^ b.length;
821
+ for (let i = 0; i < Math.max(a.length, b.length); i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
822
+ return diff === 0;
823
+ }
824
+ `;
825
+ }
826
+
827
+ /** `src/routes/admin/_layout.ts`: la barra con las tablas. */
828
+ export function marcoAdmin(datos) {
829
+ return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
830
+ import type { LayoutProps } from "vite-plugin-ascua/site";
831
+
832
+ import { adminTables } from "${datos}";
833
+
834
+ type AdminTable = (typeof adminTables)[number];
835
+
836
+ /** Every admin page: the tables on the side, the page on the right. */
837
+ export default function AdminLayout(props: LayoutProps) {
838
+ const frame = view\`
839
+ <div class="admin">
840
+ <nav>
841
+ <a href="/admin"><strong>Admin</strong></a>
842
+ <For each=\${() => adminTables} key=\${(t: AdminTable) => t.name}
843
+ render=\${(t: AdminTable) => view\`<a href=\${t.path} class:current=\${props.path.startsWith(t.path)}>\${t.label}</a>\`}/>
844
+ </nav>
845
+ <div class="page"></div>
846
+ <style>
847
+ .admin { display: grid; grid-template-columns: 12rem 1fr; gap: 1.5rem; }
848
+ .admin nav { display: grid; align-content: start; gap: .25rem; }
849
+ .admin nav .current { font-weight: bold; }
850
+ @media (max-width: 40rem) { .admin { grid-template-columns: 1fr; } }
851
+ </style>
852
+ </div>\`;
853
+ props.children(frame.querySelector(".page")!);
854
+ return frame;
855
+ }
856
+ `;
857
+ }
858
+
859
+ /** `src/routes/admin/index.ts`: el panel. */
860
+ export function panelAdmin(datos) {
861
+ return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
862
+ import { adminTables, skippedTables } from "${datos}";
863
+
864
+ export const title = "Admin";
865
+
866
+ type AdminTable = (typeof adminTables)[number];
867
+ type Skipped = (typeof skippedTables)[number];
868
+
869
+ export default function Admin() {
870
+ return view\`
871
+ <section>
872
+ <h1>Admin</h1>
873
+ <table>
874
+ <thead><tr><th>Table</th><th></th></tr></thead>
875
+ <tbody>
876
+ <For each=\${() => adminTables} key=\${(t: AdminTable) => t.name}
877
+ render=\${(t: AdminTable) => view\`<tr><td><a href=\${t.path}>\${t.label}</a></td><td><a href=\${\`\${t.path}/new\`}>New</a> · <a href=\${\`\${t.path}/grid\`}>Grid</a></td></tr>\`}/>
878
+ </tbody>
879
+ </table>
880
+ <Show when=\${() => skippedTables.length > 0}>
881
+ <h2>Left out</h2>
882
+ <ul>
883
+ <For each=\${() => skippedTables} key=\${(t: Skipped) => t.name}
884
+ render=\${(t: Skipped) => view\`<li><code>\${t.name}</code>: \${t.reason}</li>\`}/>
885
+ </ul>
886
+ </Show>
887
+ </section>\`;
888
+ }
889
+ `;
890
+ }
891
+
768
892
  /** `src/pool.ts`: un pool para todo el servidor, con las migraciones aplicadas una vez. */
769
893
  export function piscina({ mysql, migrate, db }) {
770
894
  if (mysql) {
package/index.js CHANGED
@@ -16,10 +16,11 @@ import { createRequire } from "node:module";
16
16
  import { dirname, join, relative, resolve } from "node:path";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
18
 
19
- import { analizar, ayudaRejilla, consultasSql, nombres, piscina, recurso, rejilla, rutas, ScaffoldError, t } from "./generate.js";
19
+ import { analizar, ayudaRejilla, consultasSql, datosAdmin, guardiaAdmin, marcoAdmin, nombres, panelAdmin, piscina, recurso, rejilla, rutas, ScaffoldError, t } from "./generate.js";
20
20
 
21
21
  const AYUDA = t(
22
22
  `Uso: ascua-scaffold <tabla> [opciones]
23
+ ascua-scaffold --admin [opciones]
23
24
 
24
25
  Genera, a partir de una tabla de las migraciones de brasa:
25
26
  db/queries/<tabla>.sql List, Get, Create, Update y Delete
@@ -33,9 +34,11 @@ Genera, a partir de una tabla de las migraciones de brasa:
33
34
 
34
35
  Opciones:
35
36
  --config <ruta> el brasa.json (por defecto, el del directorio actual)
37
+ --admin todas las tablas bajo /admin, con su panel; pide ADMIN_PASSWORD
36
38
  --force reescribe los archivos que ya existan
37
39
  -h, --help esta ayuda`,
38
40
  `Usage: ascua-scaffold <table> [options]
41
+ ascua-scaffold --admin [options]
39
42
 
40
43
  Generates, from a table in the brasa migrations:
41
44
  db/queries/<table>.sql List, Get, Create, Update and Delete
@@ -49,6 +52,7 @@ Generates, from a table in the brasa migrations:
49
52
 
50
53
  Options:
51
54
  --config <path> the brasa.json (default: the one in the current directory)
55
+ --admin every table under /admin, with a dashboard; asks for ADMIN_PASSWORD
52
56
  --force overwrite files that already exist
53
57
  -h, --help this help`,
54
58
  );
@@ -57,11 +61,12 @@ const POR_DEFECTO = { dialect: "postgres", migrations: "db/migrations", queries:
57
61
 
58
62
  /** `["posts", "--force"]` → `{ table: "posts", force: true }`. */
59
63
  export function leerArgumentos(argv) {
60
- const salida = { table: undefined, config: "brasa.json", force: false, ayuda: false };
64
+ const salida = { table: undefined, config: "brasa.json", force: false, admin: false, ayuda: false };
61
65
  for (let i = 0; i < argv.length; i++) {
62
66
  const a = argv[i];
63
67
  if (a === "-h" || a === "--help") salida.ayuda = true;
64
68
  else if (a === "--force") salida.force = true;
69
+ else if (a === "--admin") salida.admin = true;
65
70
  else if (a === "--config") salida.config = argv[++i];
66
71
  else if (a.startsWith("--config=")) salida.config = a.slice("--config=".length);
67
72
  else if (!a.startsWith("-") && salida.table === undefined) salida.table = a;
@@ -99,140 +104,226 @@ function importar(desde, hacia) {
99
104
  return r;
100
105
  }
101
106
 
102
- export async function main(argv = process.argv.slice(2)) {
103
- const args = leerArgumentos(argv);
104
- if (args.ayuda || !args.table) {
105
- console.log(AYUDA);
106
- return args.ayuda ? 0 : 1;
107
- }
108
-
107
+ /** brasa.json, el esquema y el brasa del proyecto. */
108
+ async function proyecto(args) {
109
109
  const rutaConfig = resolve(args.config);
110
110
  const raiz = dirname(rutaConfig);
111
111
  if (!existsSync(rutaConfig)) {
112
112
  throw new ScaffoldError(t(`no existe ${rutaConfig}: crea uno con \`brasa init\``, `${rutaConfig} does not exist: create one with \`brasa init\``));
113
113
  }
114
114
  const config = { ...POR_DEFECTO, ...JSON.parse(readFileSync(rutaConfig, "utf8")) };
115
- const mysql = config.dialect === "mysql";
116
- const dirMigraciones = resolve(raiz, config.migrations);
117
- const dirConsultas = resolve(raiz, config.queries);
118
- const out = resolve(raiz, config.out);
119
-
120
115
  const brasa = await cargarBrasa(raiz);
121
- const migrations = leerSql(dirMigraciones);
122
- const queries = leerSql(dirConsultas);
123
- const salida = brasa.compile({ dialect: config.dialect, migrate: config.migrate, migrations, queries });
116
+ const dirConsultas = resolve(raiz, config.queries);
117
+ const salida = brasa.compile({
118
+ dialect: config.dialect,
119
+ migrate: config.migrate,
120
+ migrations: leerSql(resolve(raiz, config.migrations)),
121
+ queries: leerSql(dirConsultas),
122
+ });
124
123
  if (salida.code === null) {
125
124
  throw new ScaffoldError(t("el esquema o las consultas tienen errores: corre `brasa generate` para verlos", "the schema or the queries have errors: run `brasa generate` to see them"));
126
125
  }
127
126
  if (!salida.tables) {
128
127
  throw new ScaffoldError(t("este brasa-sql no describe las tablas: actualízalo", "this brasa-sql does not describe tables: update it"));
129
128
  }
129
+ return {
130
+ rutaConfig,
131
+ raiz,
132
+ config,
133
+ brasa,
134
+ salida,
135
+ dirConsultas,
136
+ mysql: config.dialect === "mysql",
137
+ out: resolve(raiz, config.out),
138
+ pool: join(raiz, "src", "pool.ts"),
139
+ ayuda: join(raiz, "src", "resources", "grid.ts"),
140
+ };
141
+ }
130
142
 
131
- const tabla = salida.tables.find((x) => x.name === args.table) ?? salida.tables.find((x) => x.name.toLowerCase() === args.table.toLowerCase());
132
- if (!tabla) {
133
- const hay = salida.tables.filter((x) => !x.view).map((x) => x.name);
134
- throw new ScaffoldError(
135
- t(`no hay una tabla \`${args.table}\` en las migraciones. Hay: ${hay.join(", ") || "ninguna"}`, `there is no \`${args.table}\` table in the migrations. There are: ${hay.join(", ") || "none"}`),
136
- );
137
- }
138
-
139
- const a = analizar(tabla, { mysql });
143
+ /**
144
+ * Los archivos de una tabla y lo que llevan. Lanza `ScaffoldError` si no se
145
+ * puede generar. Con `reutilizar`, una tabla que ya tiene sus cinco consultas
146
+ * y su `resources/<tabla>.ts` los conserva y solo recibe rutas nuevas.
147
+ */
148
+ function planTabla(p, tabla, { prefijo, force, reutilizar }) {
149
+ const a = analizar(tabla, { mysql: p.mysql });
140
150
  const n = nombres(tabla);
141
- // Con --force, las del archivo que se va a reescribir no cuentan.
142
- const propio = join(dirConsultas, `${tabla.name}.sql`);
143
- const ajenas = salida.queries.filter((x) => !(args.force && resolve(x.file) === propio));
144
- const repetidas = [n.list, n.get, n.create, n.update, n.delete].filter((q) => ajenas.some((x) => x.name === q));
145
- const enPropio = repetidas.filter((q) => salida.queries.some((x) => x.name === q && resolve(x.file) === propio));
146
- if (repetidas.length && enPropio.length === repetidas.length) {
147
- // Las del ejemplo de `brasa init`, por ejemplo: están en el archivo que se va a escribir.
148
- const archivo = relative(process.cwd(), propio);
149
- throw new ScaffoldError(
150
- t(`${archivo} ya tiene ${repetidas.join(", ")}. Con --force se reemplaza ese archivo`, `${archivo} already has ${repetidas.join(", ")}. Use --force to replace that file`),
151
- );
152
- }
153
- if (repetidas.length) {
154
- throw new ScaffoldError(
155
- t(`ya hay consultas con estos nombres: ${repetidas.join(", ")}. Quita esas o genera otra tabla`, `queries with these names already exist: ${repetidas.join(", ")}. Remove them or scaffold another table`),
156
- );
157
- }
158
-
159
151
  const carpeta = tabla.name.replace(/_/g, "-").toLowerCase();
160
152
  if (carpeta === "grid") {
161
153
  throw new ScaffoldError(t("una tabla `grid` chocaría con src/resources/grid.ts, que comparten las grillas", "a `grid` table would clash with src/resources/grid.ts, which the grids share"));
162
154
  }
163
155
  // Los enums de Postgres tienen su `<x>Values` en el módulo; los de MySQL
164
156
  // (`ENUM('a','b')` en la columna), no: sus valores van escritos.
165
- const enums = new Set(mysql ? [] : tabla.columns.filter((c) => c.kind === "enum").map((c) => c.sqlType));
157
+ const enums = new Set(p.mysql ? [] : tabla.columns.filter((c) => c.kind === "enum").map((c) => c.sqlType));
166
158
 
159
+ const sql = join(p.dirConsultas, `${tabla.name}.sql`);
160
+ const archivoRecurso = join(p.raiz, "src", "resources", `${carpeta}.ts`);
161
+ const consultas = [n.list, n.get, n.create, n.update, n.delete];
162
+ const hay = consultas.filter((q) => p.salida.queries.some((x) => x.name === q));
163
+ const reusa = reutilizar && !force && hay.length === consultas.length && existsSync(archivoRecurso);
164
+
165
+ if (!reusa) {
166
+ // Con --force, las del archivo que se va a reescribir no cuentan.
167
+ const ajenas = p.salida.queries.filter((x) => !(force && resolve(x.file) === sql));
168
+ const repetidas = consultas.filter((q) => ajenas.some((x) => x.name === q));
169
+ const enPropio = repetidas.filter((q) => p.salida.queries.some((x) => x.name === q && resolve(x.file) === sql));
170
+ if (repetidas.length && enPropio.length === repetidas.length) {
171
+ // Las del ejemplo de `brasa init`, por ejemplo: están en el archivo que se va a escribir.
172
+ const archivo = relative(process.cwd(), sql);
173
+ throw new ScaffoldError(t(`${archivo} ya tiene ${repetidas.join(", ")}. Con --force se reemplaza ese archivo`, `${archivo} already has ${repetidas.join(", ")}. Use --force to replace that file`));
174
+ }
175
+ if (repetidas.length) {
176
+ throw new ScaffoldError(t(`ya hay consultas con estos nombres: ${repetidas.join(", ")}. Quita esas o genera otra tabla`, `queries with these names already exist: ${repetidas.join(", ")}. Remove them or scaffold another table`));
177
+ }
178
+ }
179
+
180
+ const dirRutas = join(p.raiz, "src", "routes", ...(prefijo ? [prefijo] : []), carpeta);
181
+ const base = `${prefijo ? `/${prefijo}` : ""}/${carpeta}`;
167
182
  const archivos = {
168
- sql: join(dirConsultas, `${tabla.name}.sql`),
169
- recurso: join(raiz, "src", "resources", `${carpeta}.ts`),
170
- index: join(raiz, "src", "routes", carpeta, "index.ts"),
171
- nuevo: join(raiz, "src", "routes", carpeta, "new.ts"),
172
- editar: join(raiz, "src", "routes", carpeta, "[id].ts"),
173
- rejilla: join(raiz, "src", "routes", carpeta, "grid.ts"),
174
- pool: join(raiz, "src", "pool.ts"),
175
- ayuda: join(raiz, "src", "resources", "grid.ts"),
183
+ sql,
184
+ recurso: archivoRecurso,
185
+ index: join(dirRutas, "index.ts"),
186
+ nuevo: join(dirRutas, "new.ts"),
187
+ editar: join(dirRutas, "[id].ts"),
188
+ rejilla: join(dirRutas, "grid.ts"),
176
189
  };
177
- const existentes = Object.entries(archivos).filter(([k, f]) => k !== "pool" && k !== "ayuda" && existsSync(f));
178
- if (existentes.length && !args.force) {
179
- throw new ScaffoldError(
180
- t(`ya existen: ${existentes.map(([, f]) => relative(raiz, f)).join(", ")}. Usa --force para reescribirlos`, `already exist: ${existentes.map(([, f]) => relative(raiz, f)).join(", ")}. Use --force to overwrite them`),
181
- );
190
+ if (reusa) {
191
+ delete archivos.sql;
192
+ delete archivos.recurso;
193
+ }
194
+ const existentes = Object.values(archivos).filter((f) => existsSync(f));
195
+ if (existentes.length && !force) {
196
+ const lista = existentes.map((f) => relative(p.raiz, f)).join(", ");
197
+ throw new ScaffoldError(t(`ya existen: ${lista}. Usa --force para reescribirlos`, `already exist: ${lista}. Use --force to overwrite them`));
182
198
  }
183
199
 
184
200
  const r = rutas(tabla, a, n, {
185
- db: { index: importar(archivos.index, out), nested: importar(archivos.nuevo, out) },
186
- pool: { index: importar(archivos.index, archivos.pool), nested: importar(archivos.nuevo, archivos.pool) },
187
- resource: { nested: importar(archivos.nuevo, archivos.recurso) },
188
- base: `/${carpeta}`,
201
+ db: { index: importar(archivos.index, p.out), nested: importar(archivos.nuevo, p.out) },
202
+ pool: { index: importar(archivos.index, p.pool), nested: importar(archivos.nuevo, p.pool) },
203
+ resource: { nested: importar(archivos.nuevo, archivoRecurso) },
204
+ base,
189
205
  });
190
206
  const contenidos = {
191
- sql: consultasSql(tabla, a, n),
192
- recurso: recurso(tabla, a, n, { db: importar(archivos.recurso, out), enums }),
193
207
  index: r.index,
194
208
  nuevo: r.nuevo,
195
209
  editar: r.editar,
196
210
  rejilla: rejilla(tabla, a, n, {
197
- db: importar(archivos.rejilla, out),
198
- pool: importar(archivos.rejilla, archivos.pool),
199
- resource: importar(archivos.rejilla, archivos.recurso),
200
- grid: importar(archivos.rejilla, archivos.ayuda),
201
- base: `/${carpeta}`,
211
+ db: importar(archivos.rejilla, p.out),
212
+ pool: importar(archivos.rejilla, p.pool),
213
+ resource: importar(archivos.rejilla, archivoRecurso),
214
+ grid: importar(archivos.rejilla, p.ayuda),
215
+ base,
202
216
  enums,
203
217
  }),
204
218
  };
205
- const escritos = [];
206
- for (const [k, contenido] of Object.entries(contenidos)) {
207
- mkdirSync(dirname(archivos[k]), { recursive: true });
208
- writeFileSync(archivos[k], contenido);
209
- escritos.push(archivos[k]);
219
+ if (!reusa) {
220
+ contenidos.sql = consultasSql(tabla, a, n);
221
+ contenidos.recurso = recurso(tabla, a, n, { db: importar(archivoRecurso, p.out), enums });
210
222
  }
211
- if (!existsSync(archivos.ayuda)) {
212
- writeFileSync(archivos.ayuda, ayudaRejilla());
213
- escritos.push(archivos.ayuda);
214
- }
215
- if (!existsSync(archivos.pool)) {
216
- writeFileSync(archivos.pool, piscina({ mysql, migrate: config.migrate, db: importar(archivos.pool, out) }));
217
- escritos.push(archivos.pool);
223
+ return { tabla: tabla.name, base, archivos, contenidos, reusa };
224
+ }
225
+
226
+ /** Escribe los planes y lo común (el pool, el ayudante de la grilla); devuelve lo escrito. */
227
+ function escribir(p, planes, extra = {}) {
228
+ const escritos = [];
229
+ const poner = (archivo, contenido) => {
230
+ mkdirSync(dirname(archivo), { recursive: true });
231
+ writeFileSync(archivo, contenido);
232
+ escritos.push(archivo);
233
+ };
234
+ for (const plan of planes) {
235
+ for (const [k, contenido] of Object.entries(plan.contenidos)) poner(plan.archivos[k], contenido);
218
236
  }
237
+ for (const [archivo, contenido] of Object.entries(extra)) poner(archivo, contenido);
238
+ if (!existsSync(p.ayuda)) poner(p.ayuda, ayudaRejilla());
239
+ if (!existsSync(p.pool)) poner(p.pool, piscina({ mysql: p.mysql, migrate: p.config.migrate, db: importar(p.pool, p.out) }));
240
+ return escritos;
241
+ }
219
242
 
220
- // El módulo de brasa, con las consultas nuevas.
221
- const generado = spawnSync(process.execPath, [brasa.bin, "generate", "--config", rutaConfig], { cwd: raiz, stdio: "inherit" });
243
+ /** El módulo de brasa con las consultas nuevas, y lo que falta en el proyecto. */
244
+ function terminar(p, escritos, listo) {
245
+ const generado = spawnSync(process.execPath, [p.brasa.bin, "generate", "--config", p.rutaConfig], { cwd: p.raiz, stdio: "inherit" });
222
246
  if (generado.status !== 0) {
223
247
  throw new ScaffoldError(t("brasa no pudo generar el módulo con las consultas nuevas (arriba, el error)", "brasa could not generate the module with the new queries (the error is above)"));
224
248
  }
225
-
226
249
  for (const f of escritos) console.log(` ${relative(process.cwd(), f)}`);
227
- const paquete = existsSync(join(raiz, "package.json")) ? JSON.parse(readFileSync(join(raiz, "package.json"), "utf8")) : {};
250
+ const paquete = existsSync(join(p.raiz, "package.json")) ? JSON.parse(readFileSync(join(p.raiz, "package.json"), "utf8")) : {};
228
251
  const deps = { ...paquete.dependencies, ...paquete.devDependencies };
229
- const faltan = (mysql ? ["mysql2"] : ["pg", "@types/pg"]).concat("@types/node").filter((d) => !deps[d]);
252
+ const faltan = (p.mysql ? ["mysql2"] : ["pg", "@types/pg"]).concat("@types/node").filter((d) => !deps[d]);
230
253
  console.log("");
231
- console.log(t(`Listo: /${carpeta} en el sitio.`, `Done: /${carpeta} on the site.`));
254
+ console.log(listo);
232
255
  if (faltan.length) console.log(t(`Falta instalar: stil add ${faltan.join(" ")}`, `Still to install: npm i ${faltan.join(" ")}`));
233
- if (!/mode:\s*["']server["']/.test(leer(join(raiz, "vite.config.ts")))) {
256
+ if (!/mode:\s*["']server["']/.test(leer(join(p.raiz, "vite.config.ts")))) {
234
257
  console.log(t('Las acciones necesitan servidor: ascua({ site: { mode: "server" } }) en vite.config.ts', 'Actions need a server: ascua({ site: { mode: "server" } }) in vite.config.ts'));
235
258
  }
259
+ }
260
+
261
+ /** `--admin`: todas las tablas bajo /admin, con su panel y su contraseña. */
262
+ function admin(p, args) {
263
+ const planes = [];
264
+ const saltadas = [];
265
+ const conservadas = [];
266
+ for (const tabla of p.salida.tables) {
267
+ // Una tabla que ya tiene su página en el admin se queda como está.
268
+ const carpeta = tabla.name.replace(/_/g, "-").toLowerCase();
269
+ if (!args.force && existsSync(join(p.raiz, "src", "routes", "admin", carpeta, "index.ts"))) {
270
+ conservadas.push({ name: tabla.name, path: `/admin/${carpeta}` });
271
+ continue;
272
+ }
273
+ try {
274
+ planes.push(planTabla(p, tabla, { prefijo: "admin", force: args.force, reutilizar: true }));
275
+ } catch (error) {
276
+ if (!(error instanceof ScaffoldError)) throw error;
277
+ saltadas.push({ name: tabla.name, reason: error.message });
278
+ }
279
+ }
280
+ if (planes.length === 0 && conservadas.length === 0) {
281
+ const motivos = saltadas.map((x) => `\n ${x.name}: ${x.reason}`).join("");
282
+ throw new ScaffoldError(t(`ninguna tabla se pudo generar:${motivos}`, `no table could be scaffolded:${motivos}`));
283
+ }
284
+
285
+ const dir = join(p.raiz, "src", "routes", "admin");
286
+ const datos = join(p.raiz, "src", "resources", "admin.ts");
287
+ // En el orden de las migraciones, las de antes y las nuevas.
288
+ const orden = p.salida.tables.map((x) => x.name);
289
+ const incluidas = [...conservadas, ...planes.map((x) => ({ name: x.tabla, path: x.base }))].sort((a, b) => orden.indexOf(a.name) - orden.indexOf(b.name));
290
+ const extra = { [datos]: datosAdmin(incluidas, saltadas) };
291
+ const propios = {
292
+ [join(dir, "_middleware.ts")]: guardiaAdmin(),
293
+ [join(dir, "_layout.ts")]: marcoAdmin(importar(join(dir, "_layout.ts"), datos)),
294
+ [join(dir, "index.ts")]: panelAdmin(importar(join(dir, "index.ts"), datos)),
295
+ };
296
+ // El panel, la barra y la contraseña son tuyos: se escriben si faltan.
297
+ for (const [archivo, contenido] of Object.entries(propios)) {
298
+ if (args.force || !existsSync(archivo)) extra[archivo] = contenido;
299
+ }
300
+
301
+ const escritos = escribir(p, planes, extra);
302
+ terminar(p, escritos, t(`Listo: /admin con ${incluidas.length} tablas (${planes.length} nuevas).`, `Done: /admin with ${incluidas.length} tables (${planes.length} new).`));
303
+ for (const plan of planes.filter((x) => x.reusa)) {
304
+ console.log(t(` ${plan.tabla}: usa las consultas y el formulario que ya tenía`, ` ${plan.tabla}: uses the queries and form it already had`));
305
+ }
306
+ for (const x of saltadas) console.log(t(` fuera: ${x.name}: ${x.reason}`, ` left out: ${x.name}: ${x.reason}`));
307
+ console.log(t("Pide usuario y contraseña: define ADMIN_PASSWORD (y ADMIN_USER, «admin» por defecto). Sin ella, /admin es un 404.", "It asks for a user and password: set ADMIN_PASSWORD (and ADMIN_USER, \"admin\" by default). Without it, /admin is a 404."));
308
+ return 0;
309
+ }
310
+
311
+ export async function main(argv = process.argv.slice(2)) {
312
+ const args = leerArgumentos(argv);
313
+ if (args.ayuda || (!args.table && !args.admin)) {
314
+ console.log(AYUDA);
315
+ return args.ayuda ? 0 : 1;
316
+ }
317
+ const p = await proyecto(args);
318
+ if (args.admin) return admin(p, args);
319
+
320
+ const tabla = p.salida.tables.find((x) => x.name === args.table) ?? p.salida.tables.find((x) => x.name.toLowerCase() === args.table.toLowerCase());
321
+ if (!tabla) {
322
+ const hay = p.salida.tables.filter((x) => !x.view).map((x) => x.name);
323
+ throw new ScaffoldError(t(`no hay una tabla \`${args.table}\` en las migraciones. Hay: ${hay.join(", ") || "ninguna"}`, `there is no \`${args.table}\` table in the migrations. There are: ${hay.join(", ") || "none"}`));
324
+ }
325
+ const plan = planTabla(p, tabla, { prefijo: "", force: args.force, reutilizar: false });
326
+ terminar(p, escribir(p, [plan]), t(`Listo: ${plan.base} en el sitio.`, `Done: ${plan.base} on the site.`));
236
327
  return 0;
237
328
  }
238
329
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ascua-scaffold",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "De una tabla de brasa a un recurso de Ascua: consultas, formulario validado y rutas para listar, crear, editar y borrar.",
5
5
  "keywords": ["ascua", "scaffold", "crud", "brasa", "sql", "forms"],
6
6
  "homepage": "https://ascua.gitweave.run",