@nera-static/nera 1.6.0 → 1.7.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.
package/README.md CHANGED
@@ -28,7 +28,7 @@ package is named after the folder (`My Bakery` → `my-bakery`).
28
28
  | `nera build` | Render `pages/` → `public/`. With `--check`, then run `nera check` on the result — one command for CI. |
29
29
  | `nera dev` | Build, serve `public/`, and rebuild on change with live reload. Watches `pages/`, `config/`, `theme/` and a local theme (`theme: ./themes/<name>`). |
30
30
  | `nera serve` | Serve the already-built `public/` folder. |
31
- | `nera update` | Update the site's Nera packages. On a legacy cloned site, `nera update --migrate` converts it to the thin model. |
31
+ | `nera update` | Update the site's Nera packages, then create a missing `AGENTS.md`/`CLAUDE.md` and refresh the Nera part of `AGENTS.md` (see [Instructions for AI assistants](#instructions-for-ai-assistants)). On a legacy cloned site, `nera update --migrate` converts it to the thin model. |
32
32
  | `nera validate` | Check the site's sources (layouts, includes, YAML) before publishing; exits non-zero on any error. |
33
33
  | `nera check` | Check the built `public/` for accessibility, privacy and legal-notice problems (see below). Build first. |
34
34
 
@@ -56,6 +56,22 @@ the marker line `<!-- nera:site-notes … -->`; keep that line in place. The
56
56
  part above it is Nera's and may be replaced by a later Nera version; your
57
57
  notes below it are yours.
58
58
 
59
+ `nera update` keeps both files current, with no extra flag. After the
60
+ packages are updated (and after `nera update --migrate`) it:
61
+
62
+ - creates `AGENTS.md` and `CLAUDE.md` when they are missing, so a site made
63
+ before 1.6.0 gets them too;
64
+ - replaces only the part of `AGENTS.md` above the first line starting with
65
+ `<!-- nera:site-notes`, taking the new text from the installed
66
+ `@nera-static/nera`'s template; the marker line and everything below it
67
+ stay byte for byte as they are;
68
+ - leaves an `AGENTS.md` without that line alone and prints a hint: copy the
69
+ marker line from the template directly above your own notes to opt in;
70
+ - never touches an existing `CLAUDE.md`.
71
+
72
+ A file is written only when its content changes, so running `nera update`
73
+ again changes nothing.
74
+
59
75
  ## Starting from a theme
60
76
 
61
77
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nera-static/nera",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "The Nera CLI — scaffold, build, preview and update a Nera static site with one command over the @nera-static/core engine",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,83 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+
4
+ // Start of the ownership line in AGENTS.md. Everything above the first line
5
+ // that starts with it is Nera's and is refreshed by `nera update`; that line
6
+ // and everything below it belong to the site owner and are kept byte for byte.
7
+ export const SITE_NOTES_MARKER = '<!-- nera:site-notes'
8
+
9
+ // Offset of the first line that starts with SITE_NOTES_MARKER, or -1.
10
+ // Only a match at a line start counts, so the marker quoted inside a line
11
+ // never moves the split.
12
+ function markerOffset(text) {
13
+ if (text.startsWith(SITE_NOTES_MARKER)) return 0
14
+ const at = text.indexOf(`\n${SITE_NOTES_MARKER}`)
15
+ return at === -1 ? -1 : at + 1
16
+ }
17
+
18
+ async function readIfExists(file) {
19
+ try {
20
+ return await fs.readFile(file, 'utf-8')
21
+ } catch (err) {
22
+ if (err.code === 'ENOENT') return null
23
+ throw err
24
+ }
25
+ }
26
+
27
+ // The site's AGENTS.md with its Nera part replaced by the template's. The
28
+ // template's line endings follow the site's, so a CRLF checkout stays CRLF.
29
+ // null when the site file has no marker: then none of it is Nera's to touch.
30
+ function spliceAgents(siteText, templateHead) {
31
+ const at = markerOffset(siteText)
32
+ if (at === -1) return null
33
+ const eol = siteText.slice(0, at).includes('\r\n') ? '\r\n' : '\n'
34
+ return templateHead.replace(/\r?\n/g, eol) + siteText.slice(at)
35
+ }
36
+
37
+ // Bring a site's AGENTS.md and CLAUDE.md in line with the scaffold template
38
+ // in templateDir: create what is missing, refresh the Nera part of an
39
+ // AGENTS.md that has the marker, leave a marker-less AGENTS.md and any
40
+ // existing CLAUDE.md alone. Writes only on change, so a repeated run is a
41
+ // no-op. Returns one { file, action } per file, action being `created`,
42
+ // `updated`, `unchanged` or `skipped`; printing is the caller's job.
43
+ export async function syncAgentFiles({ cwd = process.cwd(), templateDir }) {
44
+ const template = await fs.readFile(path.join(templateDir, 'AGENTS.md'), 'utf-8')
45
+ const templateAt = markerOffset(template)
46
+ if (templateAt === -1) {
47
+ throw new Error(
48
+ `The template's AGENTS.md (${templateDir}) has no ` +
49
+ `\`${SITE_NOTES_MARKER}\` line. This is a packaging bug in ` +
50
+ '@nera-static/nera, not a problem with your site.'
51
+ )
52
+ }
53
+
54
+ const agentsPath = path.join(cwd, 'AGENTS.md')
55
+ const agents = await readIfExists(agentsPath)
56
+ let agentsAction
57
+ if (agents === null) {
58
+ await fs.writeFile(agentsPath, template)
59
+ agentsAction = 'created'
60
+ } else {
61
+ const next = spliceAgents(agents, template.slice(0, templateAt))
62
+ if (next === null) {
63
+ agentsAction = 'skipped'
64
+ } else if (next === agents) {
65
+ agentsAction = 'unchanged'
66
+ } else {
67
+ await fs.writeFile(agentsPath, next)
68
+ agentsAction = 'updated'
69
+ }
70
+ }
71
+
72
+ const claudePath = path.join(cwd, 'CLAUDE.md')
73
+ let claudeAction = 'unchanged'
74
+ if ((await readIfExists(claudePath)) === null) {
75
+ await fs.copyFile(path.join(templateDir, 'CLAUDE.md'), claudePath)
76
+ claudeAction = 'created'
77
+ }
78
+
79
+ return [
80
+ { file: 'AGENTS.md', action: agentsAction },
81
+ { file: 'CLAUDE.md', action: claudeAction },
82
+ ]
83
+ }
@@ -2,6 +2,8 @@ import { execFileSync } from 'child_process'
2
2
  import fs from 'fs/promises'
3
3
  import fssync from 'fs'
4
4
  import path from 'path'
5
+ import { syncAgentFiles, SITE_NOTES_MARKER } from '../agent-files.js'
6
+ import { templateDir as packageTemplateDir } from '../scaffold.js'
5
7
 
6
8
  const defaultRunNpm = (npmArgs, cwd) =>
7
9
  execFileSync('npm', npmArgs, { cwd, stdio: 'inherit' })
@@ -45,7 +47,46 @@ function printLegacyGuidance() {
45
47
  console.log('\n👉 Run `nera update --migrate` to do it.')
46
48
  }
47
49
 
48
- async function migrateLegacy(cwd, pkg, runNpm) {
50
+ // The scaffold template whose AGENTS.md / CLAUDE.md the site is synced to:
51
+ // the one in the site's installed @nera-static/nera (read after npm has
52
+ // updated it), so the instructions match the Nera version the site runs even
53
+ // when a global or older `nera` performs the update. Falls back to the
54
+ // running package's template when nothing is installed there or the installed
55
+ // version predates 1.6.0, which shipped no AGENTS.md.
56
+ export function agentTemplateDir(cwd, fallbackDir) {
57
+ const installed = path.join(cwd, 'node_modules', '@nera-static', 'nera', 'template')
58
+ return fssync.existsSync(path.join(installed, 'AGENTS.md')) ? installed : fallbackDir
59
+ }
60
+
61
+ const AGENT_FILE_MESSAGES = {
62
+ created: (file) => ` ✓ Created ${file}`,
63
+ updated: (file) => ` ✓ Refreshed the Nera part of ${file}`,
64
+ // Neutral on purpose: an existing CLAUDE.md is kept without comparing it.
65
+ unchanged: (file) => ` ✓ ${file} unchanged`,
66
+ skipped: (file) => ` ⚠️ Left ${file} alone: it has no \`${SITE_NOTES_MARKER}\` line`,
67
+ }
68
+
69
+ async function syncAndReportAgentFiles(cwd, fallbackDir) {
70
+ const dir = agentTemplateDir(cwd, fallbackDir)
71
+ const results = await syncAgentFiles({ cwd, templateDir: dir })
72
+ for (const { file, action } of results) {
73
+ console.log(AGENT_FILE_MESSAGES[action](file))
74
+ }
75
+ if (results.some(({ action }) => action === 'skipped')) {
76
+ // Relative inside the site, absolute for a global or npx-cached CLI,
77
+ // where a relative path would be a long run of `../`.
78
+ const template = path.join(dir, 'AGENTS.md')
79
+ const relative = path.relative(cwd, template)
80
+ const source = relative.startsWith('..') ? template : relative
81
+ console.log(
82
+ ` To let \`nera update\` keep Nera's instructions current, copy the \`${SITE_NOTES_MARKER}\`` +
83
+ ` line from ${source} directly above your own notes. Everything above that` +
84
+ ' line is then Nera\'s and is replaced on update; the line and everything below it stay yours.'
85
+ )
86
+ }
87
+ }
88
+
89
+ async function migrateLegacy(cwd, pkg, runNpm, templateDir) {
49
90
  console.log('🔧 Migrating this cloned site to the thin @nera-static/nera model...')
50
91
 
51
92
  pkg.dependencies = {
@@ -89,6 +130,7 @@ async function migrateLegacy(cwd, pkg, runNpm) {
89
130
 
90
131
  console.log('📦 Installing...')
91
132
  runNpm(['install'], cwd)
133
+ await syncAndReportAgentFiles(cwd, templateDir)
92
134
 
93
135
  console.log('✅ Migrated. Run `nera dev` to start.')
94
136
  if (movedPlugins) {
@@ -100,7 +142,13 @@ async function migrateLegacy(cwd, pkg, runNpm) {
100
142
  }
101
143
 
102
144
  export async function runUpdate(args = [], options = {}) {
103
- const { cwd = process.cwd(), runNpm = defaultRunNpm } = options
145
+ // templateDir: the fallback scaffold template for AGENTS.md / CLAUDE.md
146
+ // (see agentTemplateDir); injectable so tests need no installed package.
147
+ const {
148
+ cwd = process.cwd(),
149
+ runNpm = defaultRunNpm,
150
+ templateDir = packageTemplateDir(),
151
+ } = options
104
152
 
105
153
  const pkg = JSON.parse(
106
154
  await fs.readFile(path.join(cwd, 'package.json'), 'utf-8')
@@ -109,7 +157,7 @@ export async function runUpdate(args = [], options = {}) {
109
157
 
110
158
  if (kind === 'legacy') {
111
159
  if (args.includes('--migrate') || args.includes('--yes')) {
112
- await migrateLegacy(cwd, pkg, runNpm)
160
+ await migrateLegacy(cwd, pkg, runNpm, templateDir)
113
161
  } else {
114
162
  printLegacyGuidance()
115
163
  }
@@ -126,5 +174,6 @@ export async function runUpdate(args = [], options = {}) {
126
174
  const targets = neraDeps(pkg)
127
175
  console.log(`🔄 Updating Nera packages: ${targets.join(', ')}`)
128
176
  runNpm(['update', ...targets], cwd)
177
+ await syncAndReportAgentFiles(cwd, templateDir)
129
178
  console.log('✅ Up to date.')
130
179
  }