@jsonpages/cli 3.0.28 → 3.0.29

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.
@@ -30,6 +30,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/App.tsx"
30
30
  /**
31
31
  * Thin Entry Point (Tenant).
32
32
  * Data from getHydratedData (file-backed or draft); assets from public/assets/images.
33
+ * Persistence: Export JSON (download) + optional Salva su repo (POST /api/persist → git push).
33
34
  */
34
35
  import { useState, useEffect } from 'react';
35
36
  import { JsonPagesEngine } from '@jsonpages/core';
@@ -40,6 +41,7 @@ import { addSectionConfig } from '@/lib/addSectionConfig';
40
41
  import { getHydratedData } from '@/lib/draftStorage';
41
42
  import type { JsonPagesConfig } from '@jsonpages/core';
42
43
  import type { PageConfig, SiteConfig, ThemeConfig, MenuConfig } from '@/types';
44
+ import type { ProjectState } from '@jsonpages/core';
43
45
 
44
46
  import siteData from '@/data/config/site.json';
45
47
  import themeData from '@/data/config/theme.json';
@@ -84,6 +86,21 @@ function App() {
84
86
  menuConfig,
85
87
  themeCss: { tenant: tenantCss },
86
88
  addSection: addSectionConfig,
89
+ persistence: {
90
+ async saveToRepo(state: ProjectState, slug: string) {
91
+ const res = await fetch('/api/persist', {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ projectState: state, slug }),
95
+ });
96
+ const body = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
97
+ if (!res.ok) {
98
+ const msg = body.error ?? `Save failed: ${res.status}`;
99
+ alert(msg);
100
+ throw new Error(msg);
101
+ }
102
+ },
103
+ },
87
104
  assets: {
88
105
  assetsBaseUrl: '/assets',
89
106
  assetsManifest,
@@ -3411,6 +3428,176 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
3411
3428
 
3412
3429
  END_OF_FILE_CONTENT
3413
3430
  # SKIP: src/registry-types.ts è un file binario e non può essere convertito in testo.
3431
+ mkdir -p "src/server"
3432
+ echo "Creating src/server/persistToRepo.ts..."
3433
+ cat << 'END_OF_FILE_CONTENT' > "src/server/persistToRepo.ts"
3434
+ /**
3435
+ * Enterprise-grade persist: write JSON to disk and git add/commit/push.
3436
+ * Used by POST /api/persist. Credentials via env (GITHUB_TOKEN); no secrets in code.
3437
+ */
3438
+ import fs from 'fs';
3439
+ import path from 'path';
3440
+ import { simpleGit, type SimpleGit } from 'simple-git';
3441
+
3442
+ /** Minimal ProjectState shape for validation (matches Core kernel). */
3443
+ export interface PersistBody {
3444
+ projectState: {
3445
+ page: unknown;
3446
+ site: unknown;
3447
+ menu: unknown;
3448
+ theme: unknown;
3449
+ };
3450
+ slug: string;
3451
+ }
3452
+
3453
+ const SLUG_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
3454
+
3455
+ function isNonEmptyObject(v: unknown): v is Record<string, unknown> {
3456
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
3457
+ }
3458
+
3459
+ export function validatePersistBody(body: unknown): { ok: true; data: PersistBody } | { ok: false; status: number; error: string } {
3460
+ if (!isNonEmptyObject(body) || !isNonEmptyObject(body.projectState)) {
3461
+ return { ok: false, status: 400, error: 'Missing or invalid projectState' };
3462
+ }
3463
+ const { projectState, slug } = body as PersistBody;
3464
+ if (typeof slug !== 'string' || !SLUG_REGEX.test(slug)) {
3465
+ return { ok: false, status: 400, error: 'Invalid slug (use lowercase alphanumeric and hyphens)' };
3466
+ }
3467
+ if (
3468
+ projectState.page == null ||
3469
+ projectState.site == null ||
3470
+ projectState.menu == null ||
3471
+ projectState.theme == null
3472
+ ) {
3473
+ return { ok: false, status: 400, error: 'projectState must include page, site, menu, theme' };
3474
+ }
3475
+ return { ok: true, data: { projectState, slug } };
3476
+ }
3477
+
3478
+ export interface PersistOptions {
3479
+ /** Absolute path to data dir (e.g. .../src/data). */
3480
+ dataDir: string;
3481
+ /** Git working directory (repo root). Defaults to dataDir's app root. */
3482
+ gitDir: string;
3483
+ /** Optional: inject into origin for push (e.g. GITHUB_TOKEN). */
3484
+ token?: string;
3485
+ }
3486
+
3487
+ function writeFiles(data: PersistBody, dataDir: string): string[] {
3488
+ const configDir = path.join(dataDir, 'config');
3489
+ const pagesDir = path.join(dataDir, 'pages');
3490
+ const written: string[] = [];
3491
+
3492
+ [configDir, pagesDir].forEach((dir) => {
3493
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
3494
+ });
3495
+
3496
+ const { projectState, slug } = data;
3497
+ const sitePath = path.join(configDir, 'site.json');
3498
+ const themePath = path.join(configDir, 'theme.json');
3499
+ const menuPath = path.join(configDir, 'menu.json');
3500
+ const pagePath = path.join(pagesDir, `${slug}.json`);
3501
+
3502
+ fs.writeFileSync(sitePath, JSON.stringify(projectState.site, null, 2), 'utf8');
3503
+ written.push(sitePath);
3504
+ fs.writeFileSync(themePath, JSON.stringify(projectState.theme, null, 2), 'utf8');
3505
+ written.push(themePath);
3506
+ fs.writeFileSync(menuPath, JSON.stringify(projectState.menu, null, 2), 'utf8');
3507
+ written.push(menuPath);
3508
+ fs.writeFileSync(pagePath, JSON.stringify(projectState.page, null, 2), 'utf8');
3509
+ written.push(pagePath);
3510
+
3511
+ return written;
3512
+ }
3513
+
3514
+ /** Inject token into HTTPS origin for push (no token stored in config). */
3515
+ function originWithToken(origin: string, token: string): string {
3516
+ if (origin.startsWith('https://')) {
3517
+ return origin.replace(/^https:\/\/([^@]*@)?/, `https://${token}@`);
3518
+ }
3519
+ return origin;
3520
+ }
3521
+
3522
+ async function gitAddCommitPush(
3523
+ gitDir: string,
3524
+ relativePaths: string[],
3525
+ commitMessage: string,
3526
+ token?: string
3527
+ ): Promise<{ success: true } | { success: false; error: string }> {
3528
+ const git: SimpleGit = simpleGit(gitDir);
3529
+ let originalOrigin: string | null = null;
3530
+
3531
+ try {
3532
+ const status = await git.status();
3533
+ if (status.isBare()) {
3534
+ return { success: false, error: 'Repository is bare; cannot commit' };
3535
+ }
3536
+
3537
+ if (token) {
3538
+ const remotes = await git.getRemotes(true);
3539
+ const origin = remotes.origin?.refs?.fetch ?? remotes.origin?.refs?.push;
3540
+ if (origin) {
3541
+ originalOrigin = origin;
3542
+ const authUrl = originWithToken(origin, token);
3543
+ await git.raw(['remote', 'set-url', 'origin', authUrl]);
3544
+ }
3545
+ }
3546
+
3547
+ await git.add(relativePaths);
3548
+ const commitResult = await git.commit(commitMessage);
3549
+ if (commitResult.commit === null && relativePaths.length > 0) {
3550
+ return { success: false, error: 'Nothing to commit (files unchanged or not in repo)' };
3551
+ }
3552
+ await git.push();
3553
+ return { success: true };
3554
+ } catch (e) {
3555
+ const msg = e instanceof Error ? e.message : String(e);
3556
+ return { success: false, error: msg };
3557
+ } finally {
3558
+ if (originalOrigin) {
3559
+ try {
3560
+ await git.raw(['remote', 'set-url', 'origin', originalOrigin]);
3561
+ } catch {
3562
+ // best-effort restore; do not mask original error
3563
+ }
3564
+ }
3565
+ }
3566
+ }
3567
+
3568
+ /**
3569
+ * Write project state to disk and push to remote.
3570
+ * File paths are resolved from dataDir; git paths are relative to gitDir.
3571
+ */
3572
+ export async function runPersist(
3573
+ data: PersistBody,
3574
+ options: PersistOptions
3575
+ ): Promise<{ success: true; message: string } | { success: false; status: number; error: string }> {
3576
+ const { dataDir, gitDir, token } = options;
3577
+
3578
+ if (!fs.existsSync(dataDir)) {
3579
+ return { success: false, status: 500, error: 'Data directory does not exist' };
3580
+ }
3581
+
3582
+ const written = writeFiles(data, dataDir);
3583
+ const relativePaths = written.map((p) => path.relative(gitDir, p)).filter((p) => !p.startsWith('..'));
3584
+ if (relativePaths.length === 0) {
3585
+ return { success: false, status: 500, error: 'Computed paths are outside git directory' };
3586
+ }
3587
+
3588
+ const commitMessage = `jsonpages: persist ${data.slug} @ ${new Date().toISOString().slice(0, 19)}Z`;
3589
+ const result = await gitAddCommitPush(gitDir, relativePaths, commitMessage, token);
3590
+
3591
+ if (result.success) {
3592
+ return { success: true, message: 'Saved and pushed to remote' };
3593
+ }
3594
+ if (result.error.toLowerCase().includes('failed to push') || result.error.toLowerCase().includes('authentication')) {
3595
+ return { success: false, status: 502, error: result.error };
3596
+ }
3597
+ return { success: false, status: 500, error: result.error };
3598
+ }
3599
+
3600
+ END_OF_FILE_CONTENT
3414
3601
  echo "Creating src/types.ts..."
3415
3602
  cat << 'END_OF_FILE_CONTENT' > "src/types.ts"
3416
3603
  import type { MenuItem } from '@jsonpages/core';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonpages/cli",
3
- "version": "3.0.28",
3
+ "version": "3.0.29",
4
4
  "description": "JsonPages CLI - Sovereign Projection Engine",
5
5
  "type": "module",
6
6
  "bin": {