@pixelated-tech/components 3.9.15 → 3.9.17

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.
@@ -8,14 +8,8 @@
8
8
  * - optionally initializes a fresh git repo and adds a remote
9
9
  *
10
10
  * TODOs (placeholders for later work):
11
- * - Create/patch pixelated.config.json and optionally run config:encrypt
12
11
  * - Run `npm ci` / `npm run lint` / `npm test` and optionally build
13
- * - Optionally create GitHub repo using API (with token from secure vault)
14
12
  *
15
- *
16
- 1) Recommended approach (short) ✅
17
- Build a small Node-based CLI (easier cross-platform than a Bash script) called e.g. scripts/new-site.js or an npm create script.
18
- Make it interactive with sensible CLI flags (--name, --domain, --repo, --git, --encrypt, --no-install) and a non-interactive mode for CI.
19
13
 
20
14
  2) High-level workflow the script should perform 🔁
21
15
  Validate inputs (target path, site slug, repo name).
@@ -39,31 +33,13 @@ certificates/ if using TLS — template may include placeholder paths.
39
33
  FEATURE_CHECKLIST.md — optionally update default checklist for new site.
40
34
  .gitignore — ensure pixelated.config.json is ignored if plaintext during setup.
41
35
 
42
- 4) Template hardening (recommended changes in pixelated-template) ⚠️
43
- Add placeholder tokens for the things above (e.g., {{PACKAGE_NAME}}, {{SITE_DOMAIN}}) rather than literal values.
44
- Add a template.json or template.meta that lists files & placeholders to auto-replace.
45
- Ensure the template does not include real secrets. Provide pixelated.config.json.example with placeholders and an example .env.local.example.
46
- Add a scripts/prepare-template.sh or test job that ensures no plaintext sensitive values remain.
47
- Document the creation process in TEMPLATE_README.md for maintainers.
48
-
49
- 5) Security & operational notes 🔐
50
- Do not commit plaintext pixelated.config.json. If the CLI accepts secret values, optionally run npm run config:encrypt immediately and only commit the .enc if needed (prefer not to commit it; store in site secrets store).
51
- Add an option to write the encrypted file directly into dist/config when building a deployment bundle.
52
- Provide an audit step: scan resulting repo for secret patterns before final commit/push.
53
-
54
36
  6) Helpful CLI implementation details (tools & libs) 🛠️
55
- Use Node + these packages: fs-extra (copy), replace-in-file or simple .replace() for template tokens, inquirer (prompt), execa (run commands), simple-git (git ops), node-fetch or @octokit/rest (optional GitHub repo creation).
56
- Use safe file writes and atomic renames for config operations.
57
37
  Provide a --dry-run and --preview mode so users can verify changes before creating repo or pushing.
58
38
 
59
39
  7) Validation & post-creation steps ✅
60
40
  npm run lint && npm test && npm run build — fail fast and show errors for the new site.
61
41
  Optional: run npm run config:decrypt locally (with provided key) to confirm decryption works in your deploy workflow (BUT DO NOT store the key in the repo).
62
42
 
63
- 8) Example minimal CLI flow (pseudo)
64
- Prompt: site name, package name, repo URL (optional), domain, author, contentful tokens, aws keys (optional), encrypt?
65
- Copy template → replace tokens → create pixelated.config.json from pixelated.config.json.example → encrypt if requested → init git → run install/build → push to remote.
66
-
67
43
  */
68
44
 
69
45
  import fs from 'fs/promises';
@@ -74,11 +50,16 @@ import readline from 'readline/promises';
74
50
  import { stdin as input, stdout as output } from 'process';
75
51
  import { fileURLToPath } from 'url';
76
52
  import { loadManifest, findTemplateForSlug, pruneTemplateDirs, printAvailableTemplates } from './create-pixelated-app-template-mapper.js';
53
+ import { AmplifyClient, CreateAppCommand, CreateBranchCommand, UpdateAppCommand, UpdateBranchCommand } from '@aws-sdk/client-amplify';
77
54
 
78
55
  const __filename = fileURLToPath(import.meta.url);
79
56
  const __dirname = path.dirname(__filename);
80
57
 
58
+ let stepNumber = 1;
59
+
81
60
  const exec = promisify(execCb);
61
+ // Exportable exec wrapper so tests can stub it.
62
+ export let _exec = exec;
82
63
 
83
64
  async function exists(p) {
84
65
  try {
@@ -232,21 +213,294 @@ export async function copyTemplateForPage(templatePathArg, templateSrc, template
232
213
  }
233
214
 
234
215
 
216
+ export async function createAndPushRemote(destPath, siteName, defaultOwner) {
217
+ // Initialize a local git repo and make the initial commit
218
+ await _exec('git init -b main', { cwd: destPath });
219
+ await _exec('git add .', { cwd: destPath });
220
+ await _exec('git commit -m "chore: initial commit from pixelated-template"', { cwd: destPath });
221
+ console.log('✅ Git initialized and initial commit created.');
222
+
223
+ // If an encrypted config exists, attempt a non-fatal decrypt in the new site to ensure the token can be read
224
+ const encCandidates = [
225
+ path.join(destPath, 'src', 'app', 'config', 'pixelated.config.json.enc'),
226
+ path.join(destPath, 'src', 'config', 'pixelated.config.json.enc'),
227
+ path.join(destPath, 'src', 'pixelated.config.json.enc'),
228
+ path.join(destPath, 'pixelated.config.json.enc'),
229
+ path.join(destPath, 'dist', 'config', 'pixelated.config.json.enc')
230
+ ];
231
+ for (const p of encCandidates) {
232
+ if (await exists(p)) {
233
+ console.log(`Found encrypted config at ${p}. Attempting to run 'npm run config:decrypt' in the new site (non-fatal)`);
234
+ try {
235
+ await _exec('npm run config:decrypt', { cwd: destPath, timeout: 60_000 });
236
+ console.log('Attempted config:decrypt (non-fatal)');
237
+ } catch (err) {
238
+ console.warn('config:decrypt failed or PIXELATED_CONFIG_KEY missing (non-fatal):', err?.message || err);
239
+ }
240
+ break;
241
+ }
242
+ }
243
+
244
+ // Create a small temporary script inside the new site to reliably import the project's provider and print JSON to stdout
245
+ const tmpDir = path.join(destPath, '.px-scripts');
246
+ const tmpFile = path.join(tmpDir, 'get_github_token.ts');
247
+ await fs.mkdir(tmpDir, { recursive: true });
248
+ const tmpContent = `import('./src/components/config/config').then(m => {
249
+ const cfg = m.getFullPixelatedConfig();
250
+ // Only print the github object (or null) as JSON to stdout
251
+ console.log(JSON.stringify(cfg?.github || null));
252
+ }).catch(e => {
253
+ console.error('ERR_IMPORT', e?.message || e);
254
+ process.exit(2);
255
+ });`;
256
+ await fs.writeFile(tmpFile, tmpContent, 'utf8');
257
+
258
+ let execOut = null;
259
+ try {
260
+ execOut = await _exec(`npx tsx ${tmpFile}`, { cwd: destPath, timeout: 60_000 });
261
+ } catch (e) {
262
+ // Provide a helpful error message and ensure cleanup happens below
263
+ console.error('❌ Failed to run config provider to obtain GitHub token. Ensure PIXELATED_CONFIG_KEY is available (e.g., in .env.local) and the site includes an encrypted pixelated.config.json.enc');
264
+ throw e;
265
+ } finally {
266
+ // Always clean up the temporary script directory
267
+ try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch (_) { /* ignore cleanup errors */ }
268
+ }
269
+
270
+ const outStr = (execOut && execOut.stdout) ? String(execOut.stdout).trim() : '';
271
+ if (!outStr) {
272
+ console.error('❌ No output from config provider; cannot locate github token');
273
+ throw new Error('Missing provider output');
274
+ }
275
+
276
+ let githubInfo = null;
277
+ try { githubInfo = JSON.parse(outStr); } catch (e) { console.error('❌ Invalid JSON from config provider:', outStr); throw e; }
278
+ const token = githubInfo?.token;
279
+ const cfgOwner = githubInfo?.defaultOwner;
280
+ if (!token) {
281
+ console.error('❌ github.token not found in decrypted config; cannot create remote repo.');
282
+ throw new Error('Missing github.token');
283
+ }
284
+
285
+ const repoName = siteName;
286
+ const ownerForMessage = cfgOwner || defaultOwner;
287
+ console.log(`Creating GitHub repo: ${ownerForMessage}/${repoName} ...`);
288
+
289
+ let resp;
290
+ try {
291
+ resp = await fetch('https://api.github.com/user/repos', {
292
+ method: 'POST',
293
+ headers: {
294
+ 'Authorization': `token ${token}`,
295
+ 'Content-Type': 'application/json',
296
+ 'User-Agent': 'create-pixelated-app'
297
+ },
298
+ body: JSON.stringify({ name: repoName, private: false })
299
+ });
300
+ } catch (e) {
301
+ console.error('❌ Failed to call GitHub API', e?.message || e);
302
+ throw e;
303
+ }
304
+
305
+ const body = await (async () => { try { return await resp.json(); } catch (e) { return null; } })();
306
+ if (!resp.ok) {
307
+ console.error(`❌ Failed to create GitHub repo: ${resp.status} ${resp.statusText} ${body?.message || ''}`);
308
+ throw new Error('GitHub repo creation failed');
309
+ }
310
+ const cloneUrl = body.clone_url;
311
+ if (!cloneUrl) {
312
+ console.error('❌ GitHub returned unexpected response (no clone_url)');
313
+ throw new Error('Invalid GitHub response');
314
+ }
315
+
316
+ // Add remote and push using repo-name as remote
317
+ const remoteName = repoName;
318
+ await _exec(`git remote add ${remoteName} ${cloneUrl}`, { cwd: destPath });
319
+ await _exec('git branch --show-current || git branch -M main', { cwd: destPath });
320
+ try {
321
+ // If we have a github token available in the decrypted config, use it for an authenticated push (avoids relying on local credential helper)
322
+ if (token) {
323
+ await _exec(`git -c credential.helper= -c http.extraheader="Authorization: token ${token}" push -u ${remoteName} main`, { cwd: destPath });
324
+ await _exec('git branch -f dev main', { cwd: destPath });
325
+ await _exec(`git -c credential.helper= -c http.extraheader="Authorization: token ${token}" push -u ${remoteName} dev`, { cwd: destPath });
326
+ } else {
327
+ await _exec(`git push -u ${remoteName} main`, { cwd: destPath });
328
+ await _exec('git branch -f dev main', { cwd: destPath });
329
+ await _exec(`git push -u ${remoteName} dev`, { cwd: destPath });
330
+ }
331
+ console.log(`✅ Remote '${remoteName}' created and pushed (main, dev): ${cloneUrl}`);
332
+ // Return useful values for downstream steps (e.g., Amplify app creation)
333
+ return { cloneUrl, remoteName, token };
334
+ } catch (e) {
335
+ console.warn('⚠️ Failed to push branches automatically. The repo was created on GitHub, but you may need to push manually or configure your git credentials. Error:', e?.message || e);
336
+ // Still return partial info so caller can decide next steps
337
+ return { cloneUrl, remoteName, token };
338
+ }
339
+ }
340
+
341
+ // Create an AWS Amplify app and connect repository branches (best-effort via AWS CLI).
342
+ // This uses the local AWS CLI configuration (credentials/profile) and optionally a GitHub
343
+ // personal access token to allow Amplify to connect to the repo automatically.
344
+ export async function createAmplifyApp(rl, siteName, cloneUrl, sitePath) {
345
+ // Use AWS region from components config if available; skip interactive region prompt
346
+ const componentsCfgPath = path.resolve(__dirname, '..', 'config', 'pixelated.config.json');
347
+ let regionToUse = 'us-east-2';
348
+ let creds = null;
349
+ try {
350
+ if (await exists(componentsCfgPath)) {
351
+ const cfgText = await fs.readFile(componentsCfgPath, 'utf8');
352
+ const cfg = JSON.parse(cfgText);
353
+ if (cfg?.aws?.region) {
354
+ regionToUse = cfg.aws.region;
355
+ console.log(`✅ Using AWS region from components config: ${regionToUse}`);
356
+ }
357
+ if (cfg?.aws?.access_key_id && cfg?.aws?.secret_access_key) {
358
+ creds = { accessKeyId: cfg.aws.access_key_id, secretAccessKey: cfg.aws.secret_access_key };
359
+ console.log('✅ Found AWS credentials in components config; they will be used for Amplify SDK operations.');
360
+ }
361
+ }
362
+ } catch (e) {
363
+ // ignore and continue without config values
364
+ }
365
+
366
+ // Prompt only for GitHub PAT (do not prompt for region)
367
+ const githubToken = (await rl.question('GitHub personal access token (PAT) to connect repo [leave blank to skip]: ')) || '';
368
+
369
+ console.log('Creating Amplify app (this may take a few seconds)...');
370
+ // Use AWS SDK Amplify client
371
+ const client = new AmplifyClient({ region: regionToUse, credentials: creds || undefined });
372
+ let createResp;
373
+ try {
374
+ createResp = await client.send(new CreateAppCommand({ name: siteName, platform: 'WEB_DYNAMIC', repository: cloneUrl || undefined, accessToken: githubToken || undefined }));
375
+ } catch (e) {
376
+ throw new Error('Failed to create Amplify app via SDK: ' + (e?.message || e));
377
+ }
378
+
379
+ const appId = createResp?.app?.appId || createResp?.appId || createResp?.id;
380
+ if (!appId) {
381
+ console.log('Amplify create app response:', createResp);
382
+ throw new Error('Unable to determine Amplify appId from SDK response');
383
+ }
384
+ console.log(`✅ Created Amplify app: ${appId}`);
385
+ console.log(`🔗 Open in console: https://${regionToUse}.console.aws.amazon.com/amplify/home?region=${regionToUse}#/d/${appId}`);
386
+
387
+ // Optionally: read site config (if sitePath provided) and set environment variables for the app & branches
388
+ let envVars = {};
389
+ if (sitePath) {
390
+ // Primary: look for a local .env.local and prefer variables defined there
391
+ const envLocalPath = path.join(sitePath, '.env.local');
392
+ if (await exists(envLocalPath)) {
393
+ try {
394
+ const envText = await fs.readFile(envLocalPath, 'utf8');
395
+ for (const line of envText.split(/\r?\n/)) {
396
+ const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*)$/);
397
+ if (!m) continue;
398
+ const k = m[1];
399
+ let v = m[2] || '';
400
+ // remove optional quotes
401
+ v = v.replace(/^"|"$/g, '');
402
+ if (k === 'PIXELATED_CONFIG_KEY' || k.startsWith('PIXELATED_CONFIG')) {
403
+ envVars[k] = v;
404
+ }
405
+ }
406
+ console.log(`✅ Loaded environment values from ${envLocalPath} and will set matching Amplify environment variables.`);
407
+ } catch (e) {
408
+ console.warn('⚠️ Failed to read .env.local for env var population:', e?.message || e);
409
+ }
410
+ }
411
+
412
+ // Secondary: if no PIXELATED_CONFIG_* vars were found in .env.local, look for pixelated.config.json
413
+ if (!envVars.PIXELATED_CONFIG_JSON && !envVars.PIXELATED_CONFIG_B64) {
414
+ const candidates = [
415
+ path.join(sitePath, 'src', 'app', 'config', 'pixelated.config.json'),
416
+ path.join(sitePath, 'src', 'config', 'pixelated.config.json'),
417
+ path.join(sitePath, 'src', 'pixelated.config.json'),
418
+ path.join(sitePath, 'pixelated.config.json')
419
+ ];
420
+ for (const c of candidates) {
421
+ if (await exists(c)) {
422
+ try {
423
+ const raw = await fs.readFile(c, 'utf8');
424
+ envVars.PIXELATED_CONFIG_JSON = raw;
425
+ envVars.PIXELATED_CONFIG_B64 = Buffer.from(raw, 'utf8').toString('base64');
426
+ console.log(`✅ Loaded site pixelated.config.json from ${c} and will set PIXELATED_CONFIG_* environment variables in Amplify.`);
427
+ break;
428
+ } catch (e) {
429
+ console.warn('⚠️ Failed to read site config for env var population:', e?.message || e);
430
+ }
431
+ }
432
+ }
433
+ }
434
+ }
435
+
436
+ // Attempt to resolve the ARN for the 'amplify-role' role (best-effort)
437
+ let iamRoleArn = null;
438
+ try {
439
+ const { IAMClient, GetRoleCommand } = await import('@aws-sdk/client-iam');
440
+ const iam = new IAMClient({ region: regionToUse, credentials: creds || undefined });
441
+ try {
442
+ const roleResp = await iam.send(new GetRoleCommand({ RoleName: 'amplify-role' }));
443
+ iamRoleArn = roleResp?.Role?.Arn;
444
+ if (iamRoleArn) console.log(`✅ Found amplify-role ARN: ${iamRoleArn}`);
445
+ } catch (e) {
446
+ // ignore; role may not exist or insufficient perms
447
+ console.warn('⚠️ Could not resolve amplify-role ARN; skipping automatic service-role assignment.');
448
+ }
449
+ } catch (e) {
450
+ // ignore if IAM client not available
451
+ }
452
+
453
+ // If we have envVars or iamRoleArn, update the app and branches accordingly
454
+ if (Object.keys(envVars).length || iamRoleArn) {
455
+ try {
456
+ const updateParams = {};
457
+ if (Object.keys(envVars).length) updateParams.environmentVariables = envVars;
458
+ if (iamRoleArn) updateParams.iamServiceRoleArn = iamRoleArn;
459
+ await client.send(new UpdateAppCommand({ appId, ...updateParams }));
460
+ console.log('✅ Amplify app updated with environment variables and IAM service role (if available).');
461
+
462
+ for (const branch of ['dev','main']) {
463
+ try {
464
+ await client.send(new UpdateBranchCommand({ appId, branchName: branch, environmentVariables: envVars }));
465
+ console.log(`✅ Updated branch '${branch}' with environment variables.`);
466
+ } catch (e) {
467
+ console.warn(`⚠️ Failed to update branch ${branch} env vars:`, e?.message || e);
468
+ }
469
+ }
470
+ } catch (e) {
471
+ console.warn('⚠️ Failed to update Amplify app/branches with env vars or service role:', e?.message || e);
472
+ }
473
+ }
474
+
475
+ console.log('ℹ️ Amplify app creation attempt finished. Verify the app in the AWS Console to ensure webhooks and branch connections are correct.');
476
+ }
477
+
235
478
  async function main() {
236
479
  const rl = readline.createInterface({ input, output });
237
480
  try {
238
481
  console.log('\n📦 Pixelated site creator — scaffold a new site from pixelated-template\n');
482
+ console.log('================================================================================\n');
483
+
239
484
 
485
+ // Prompt for basic site info
486
+ console.log(`\nStep ${stepNumber++}: Site Information`);
487
+ console.log('================================================================================\n');
240
488
  const defaultName = 'my-site';
241
489
  const siteName = (await rl.question(`Root directory name (kebab-case) [${defaultName}]: `)) || defaultName;
242
-
243
490
  // Display name used in route titles: convert kebab to Title Case
244
491
  const rootDisplayName = siteName.split(/[-_]+/).map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
245
492
 
493
+
246
494
  // Additional site metadata for placeholder substitution
495
+ console.log(`\nStep ${stepNumber++}: Site Metadata`);
496
+ console.log('================================================================================\n');
247
497
  const siteUrl = (await rl.question('Site URL (e.g. https://example.com) [leave blank to skip]: ')).trim();
248
498
  const emailAddress = (await rl.question('Contact email address [leave blank to skip]: ')).trim();
249
499
 
500
+
501
+ // Create a copy of pixelated-template inside the current workspace
502
+ console.log(`\nStep ${stepNumber++}: Template Copy`);
503
+ console.log('================================================================================\n');
250
504
  const workspaceRoot = path.resolve(__dirname, '..', '..', '..');
251
505
  const templatePath = path.resolve(workspaceRoot, 'pixelated-template');
252
506
  if (!(await exists(templatePath))) {
@@ -258,7 +512,6 @@ async function main() {
258
512
  const manifest = await loadManifest(__dirname);
259
513
  // Note: available templates will be printed later just before prompting for pages
260
514
 
261
-
262
515
  // Destination is implicitly the top-level Git folder + site name to avoid prompting for it
263
516
  const destPath = path.resolve(workspaceRoot, siteName);
264
517
  console.log(`\nThe new site will be created at: ${destPath}`);
@@ -296,7 +549,10 @@ async function main() {
296
549
  await fs.rm(gitDir, { recursive: true, force: true });
297
550
  }
298
551
 
552
+
299
553
  // Pages prompt: show available templates and ask which pages to create (comma-separated)
554
+ console.log(`\nStep ${stepNumber++}: Page Creation`);
555
+ console.log('================================================================================\n');
300
556
  if (manifest && Array.isArray(manifest.templates) && manifest.templates.length) {
301
557
  printAvailableTemplates(manifest);
302
558
  }
@@ -411,7 +667,11 @@ async function main() {
411
667
  console.log('Skipping page creation.');
412
668
  }
413
669
  }
670
+
671
+
414
672
  // Automatically replace double-underscore template placeholders (e.g., __SITE_NAME__) with provided values
673
+ console.log(`\nStep ${stepNumber++}: Placeholder Tokens Replacement`);
674
+ console.log('================================================================================\n');
415
675
  const replacements = {};
416
676
  if (rootDisplayName) replacements.SITE_NAME = rootDisplayName;
417
677
  if (siteUrl) replacements.SITE_URL = siteUrl;
@@ -435,24 +695,63 @@ async function main() {
435
695
  console.warn('⚠️ Failed to replace placeholders in site copy:', e?.message || e);
436
696
  }
437
697
  }
438
- // Prompt about git initialization
439
- const initGitAnswer = (await rl.question('Initialize a fresh git repository here? (Y/n): ')) || 'y';
440
- if (initGitAnswer.toLowerCase() === 'y' || initGitAnswer.toLowerCase() === 'yes') {
698
+
699
+
700
+
701
+ // Prompt about creating a new GitHub repository. Default owner is read from components config `github.defaultOwner` (fallback: 'brianwhaley')
702
+ console.log(`\nStep ${stepNumber++}: GitHub Repository Creation`);
703
+ console.log('================================================================================\n');
704
+ const componentsCfgPath = path.resolve(__dirname, '..', 'config', 'pixelated.config.json');
705
+ let defaultOwner = 'brianwhaley';
706
+ try {
707
+ if (await exists(componentsCfgPath)) {
708
+ const compCfgText = await fs.readFile(componentsCfgPath, 'utf8');
709
+ const compCfg = JSON.parse(compCfgText);
710
+ if (compCfg?.github?.defaultOwner) defaultOwner = compCfg.github.defaultOwner;
711
+ }
712
+ } catch (e) {
713
+ // ignore and use fallback
714
+ }
715
+ const createRemoteAnswer = (await rl.question(`Create a new GitHub repository in '${defaultOwner}' and push the initial commit? (Y/n): `)) || 'y';
716
+ let remoteInfo = null;
717
+ if (createRemoteAnswer.toLowerCase() === 'y' || createRemoteAnswer.toLowerCase() === 'yes') {
441
718
  try {
442
- await exec('git init -b main', { cwd: destPath });
443
- await exec('git add .', { cwd: destPath });
444
- await exec('git commit -m "chore: initial commit from pixelated-template"', { cwd: destPath });
445
- console.log('✅ Git initialized and initial commit created.');
719
+ remoteInfo = await createAndPushRemote(destPath, siteName, defaultOwner);
446
720
  } catch (e) {
447
- console.warn('⚠️ Git init or commit failed. You can initialize manually later.', e?.stderr || e?.message || e);
721
+ console.warn('⚠️ Repo creation or git push failed. Your local repository is still available at:', destPath);
722
+ console.warn(e?.stderr || e?.message || e);
723
+ }
724
+ }
725
+ // Optionally create an AWS Amplify app and connect branches (main, dev)
726
+ console.log(`\nStep ${stepNumber++}: AWS Amplify App Creation`);
727
+ console.log('================================================================================\n'); // Inform user what region will be used (config-backed)
728
+ try {
729
+ const cfgPath = path.resolve(__dirname, '..', 'config', 'pixelated.config.json');
730
+ if (await exists(cfgPath)) {
731
+ const cfgText = await fs.readFile(cfgPath, 'utf8');
732
+ const cfg = JSON.parse(cfgText);
733
+ if (cfg?.aws?.region) {
734
+ console.log(`ℹ️ Note: Amplify will use AWS region from config: ${cfg.aws.region}`);
735
+ }
736
+ }
737
+ } catch (e) {
738
+ // ignore errors reading config; nothing to do
739
+ } const createAmplifyAnswer = (await rl.question(`Create an AWS Amplify app for this repository and connect 'main' and 'dev' branches? (y/N): `)) || 'n';
740
+ if (createAmplifyAnswer.toLowerCase() === 'y' || createAmplifyAnswer.toLowerCase() === 'yes') {
741
+ try {
742
+ await createAmplifyApp(rl, siteName, remoteInfo?.cloneUrl);
743
+ } catch (e) {
744
+ console.warn('⚠️ Amplify app creation failed or was incomplete. You can create an app manually via the AWS Console or AWS CLI.');
745
+ console.warn(e?.stderr || e?.message || e);
448
746
  }
449
747
  }
450
748
 
451
-
452
-
453
- console.log('\n🎉 Done. Summary:');
749
+ console.log('================================================================================\n');
750
+ console.log('🎉 Done.');
751
+ console.log('================================================================================\n');
752
+ console.log('Summary:');
454
753
  console.log(` - Site copied to: ${destPath}`);
455
- console.log('\nNote: A git remote was not set by this script. You can add one later with `git remote add origin <url>` if desired.');
754
+ console.log('\nNote: A git remote was not set by this script. You can add one later with `git remote add <repo-name> <url>` (use the repository name as the remote) if desired.');
456
755
  console.log('\nNext recommended steps (manual or to be automated in future):');
457
756
  console.log(' - Update pixelated.config.json for this site and encrypt it with your config tool');
458
757
  console.log(' - Run `npm run lint`, `npm test`, and `npm run build` inside the new site and fix any issues');
@@ -469,3 +768,4 @@ if (typeof process !== 'undefined' && new URL(import.meta.url).pathname === proc
469
768
  // CLI entry point: run the interactive main flow
470
769
  main();
471
770
  }
771
+
@@ -6,8 +6,9 @@ import path from 'path';
6
6
  * Enforces workspace standards for SEO, performance, and project structure.
7
7
  */
8
8
 
9
- /* ===== CLIENT COMPONENT DETECTION (Copied from functions.ts for standalone usage) ===== */
10
- const CLIENT_ONLY_PATTERNS = [
9
+
10
+ // DUPLICATE FROM components/general/utilities.ts --- KEEP IN SYNC ---
11
+ export const CLIENT_ONLY_PATTERNS = [
11
12
  /\baddEventListener\b/,
12
13
  /\bcreateContext\b/,
13
14
  /\bdocument\./,
@@ -35,10 +36,11 @@ const CLIENT_ONLY_PATTERNS = [
35
36
  /["']use client["']/ // Client directive
36
37
  ];
37
38
 
38
- function isClientComponent(fileContent) {
39
+ export function isClientComponent(fileContent) {
39
40
  return CLIENT_ONLY_PATTERNS.some(pattern => pattern.test(fileContent));
40
41
  }
41
42
 
43
+
42
44
  const propTypesInferPropsRule = {
43
45
  meta: {
44
46
  type: 'problem',
@@ -372,6 +374,51 @@ const noRawImgRule = {
372
374
  },
373
375
  };
374
376
 
377
+ /* ===== RULE: require-section-ids ===== */
378
+ const requireSectionIdsRule = {
379
+ meta: {
380
+ type: 'suggestion',
381
+ docs: {
382
+ description: 'Require `id` attributes on every <section> and <PageSection> for jump links and SEO',
383
+ category: 'Accessibility',
384
+ recommended: false,
385
+ },
386
+ messages: {
387
+ missingId: '`section` and `PageSection` elements must have an `id` attribute for jump-link support and SEO hierarchy.',
388
+ },
389
+ schema: [],
390
+ },
391
+ create(context) {
392
+ /*
393
+ * Helper: get a string name for a JSX element. Supports
394
+ * `JSXIdentifier` and `JSXMemberExpression` (e.g. `UI.PageSection`).
395
+ */
396
+ function getJSXElementName(node) {
397
+ if (!node) return null;
398
+ if (node.type === 'JSXIdentifier') return node.name;
399
+ if (node.type === 'JSXMemberExpression') return node.property?.name || null;
400
+ return null;
401
+ }
402
+
403
+ return {
404
+ JSXOpeningElement(node) {
405
+ try {
406
+ const name = getJSXElementName(node.name); if (!name || !['section','PageSection'].includes(name)) return;
407
+
408
+ const hasId = (node.attributes || []).some(attr => (
409
+ attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'id' && attr.value != null
410
+ ));
411
+ if (!hasId) {
412
+ context.report({ node, messageId: 'missingId' });
413
+ }
414
+ } catch (e) {
415
+ // defensive: don't crash lint
416
+ }
417
+ },
418
+ };
419
+ },
420
+ };
421
+
375
422
  const requiredFaqRule = {
376
423
  meta: {
377
424
  type: 'suggestion',
@@ -423,6 +470,7 @@ export default {
423
470
  'required-schemas': requiredSchemasRule,
424
471
  'required-files': requiredFilesRule,
425
472
  'no-raw-img': noRawImgRule,
473
+ 'require-section-ids': requireSectionIdsRule,
426
474
  'required-faq': requiredFaqRule,
427
475
  },
428
476
  configs: {
@@ -432,6 +480,7 @@ export default {
432
480
  'pixelated/required-schemas': 'warn',
433
481
  'pixelated/required-files': 'warn',
434
482
  'pixelated/no-raw-img': 'warn',
483
+ 'pixelated/require-section-ids': 'warn',
435
484
  'pixelated/required-faq': 'warn',
436
485
  },
437
486
  },
@@ -38,20 +38,58 @@ fi
38
38
  echo ""
39
39
  echo "🔑 Step $((STEP_COUNT++)): Choose the git Remote to release:"
40
40
  echo "================================================="
41
- # Function to prompt for remote selection
41
+ # Helper: derive a sensible default remote (remote whose repo name matches local folder)
42
+ derive_default_remote() {
43
+ local remotes=($(git remote))
44
+ local local_repo
45
+ local_repo=$(basename "$(git rev-parse --show-toplevel)")
46
+ for remote in "${remotes[@]}"; do
47
+ url=$(git remote get-url "$remote" 2>/dev/null || true)
48
+ repo=$(basename -s .git "${url##*/}")
49
+ if [ -n "$repo" ] && [ "$repo" = "$local_repo" ]; then
50
+ echo "$remote"
51
+ return
52
+ fi
53
+ done
54
+ # Fallback to first remote if no match
55
+ echo "${remotes[0]}"
56
+ }
57
+
58
+ # Function to prompt for remote selection, showing a default derived from local repo name
42
59
  prompt_remote_selection() {
43
60
  echo "Available git remotes:" >&2
44
61
  local remotes=($(git remote))
62
+ local count=${#remotes[@]}
45
63
  local i=1
64
+
46
65
  for remote in "${remotes[@]}"; do
47
66
  echo "$i) $remote" >&2
48
67
  ((i++))
49
68
  done
69
+
70
+ local default_remote
71
+ default_remote=$(derive_default_remote)
72
+ # find index of default_remote for user prompt
73
+ local default_index=1
74
+ for idx in "${!remotes[@]}"; do
75
+ if [ "${remotes[$idx]}" = "$default_remote" ]; then
76
+ default_index=$((idx+1))
77
+ break
78
+ fi
79
+ done
80
+
81
+ local prompt="Select remote to use (1-$count) [default $default_index - $default_remote]: "
50
82
  local choice
51
- read -p "Select remote to use (1-${#remotes[@]}): " choice >&2
83
+ read -p "$prompt" choice >&2
84
+
85
+ if [ -z "$choice" ]; then
86
+ echo "$default_remote"
87
+ return
88
+ fi
89
+
52
90
  case $choice in
53
91
  [1-9]|[1-9][0-9])
54
- if [ "$choice" -le "${#remotes[@]}" ]; then
92
+ if [ "$choice" -le "$count" ]; then
55
93
  echo "${remotes[$((choice-1))]}"
56
94
  else
57
95
  echo "${remotes[0]}" # Default to first if invalid
@@ -213,7 +251,13 @@ prompt_version_type() {
213
251
  echo "3) major (1.x.x)" >&2
214
252
  echo "4) custom version" >&2
215
253
  echo "5) no version bump" >&2
216
- read -p "Enter choice (1-5): " choice >&2
254
+ read -p "Enter choice (1-5) [default 1]: " choice >&2
255
+
256
+ # Default to 1 (patch) when user presses Enter
257
+ if [ -z "$choice" ]; then
258
+ choice=1
259
+ fi
260
+
217
261
  case $choice in
218
262
  1) version_type="patch" ;;
219
263
  2) version_type="minor" ;;
@@ -223,7 +267,7 @@ prompt_version_type() {
223
267
  version_type="$custom_version"
224
268
  ;;
225
269
  5) version_type="none" ;;
226
- *) version_type="patch" ;; # default
270
+ *) version_type="patch" ;; # fallback default
227
271
  esac
228
272
  }
229
273
  prompt_version_type