caracms 0.8.2 → 0.9.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 +9 -6
- package/bin/init.mjs +69 -41
- package/bin/skills.mjs +196 -0
- package/package.json +2 -2
- package/{skills → site-skills}/caracms-adopt/SKILL.md +12 -11
- package/{skills → site-skills}/caracms-hosting/SKILL.md +3 -2
- package/{skills → site-skills}/caracms-tina/SKILL.md +1 -1
- package/src/cli/validate.ts +4 -0
- package/src/index.ts +8 -0
- package/src/live/mode.ts +8 -3
- /package/{skills → site-skills}/caracms-adopt/look-same.mjs +0 -0
- /package/{skills → site-skills}/caracms-blocks/SKILL.md +0 -0
- /package/{skills → site-skills}/caracms-content/SKILL.md +0 -0
- /package/{skills → site-skills}/caracms-tina/field-kinds.md +0 -0
- /package/{skills → site-skills}/caracms-tina/tina-to-caracms.mjs +0 -0
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ npm install
|
|
|
48
48
|
|
|
49
49
|
`init` adds the plumbing (the Node adapter, the integration, a minimal `caracms.config.ts`
|
|
50
50
|
and page component, the one catch-all route that serves every document with an address,
|
|
51
|
-
the schemas) and
|
|
51
|
+
the schemas) and copies caracms's
|
|
52
52
|
skills into `.claude/skills/`. It doesn't touch the site's pages. Bringing them in is the
|
|
53
53
|
job of the `caracms-adopt` skill, one page at a time.
|
|
54
54
|
|
|
@@ -74,7 +74,8 @@ Run from the site's root:
|
|
|
74
74
|
- `caracms start`: serve the built site (Hosting, below).
|
|
75
75
|
|
|
76
76
|
The skills (`caracms-content`, `caracms-blocks`, `caracms-adopt`, `caracms-tina` and
|
|
77
|
-
`caracms-hosting`, in `skills/`) say how the rest works
|
|
77
|
+
`caracms-hosting`, in `site-skills/`) say how the rest works. `init` copies them into the site, and brings
|
|
78
|
+
them up to date after an upgrade; `astro dev` and `caracms validate` say when they're behind.
|
|
78
79
|
|
|
79
80
|
## Collections and addresses
|
|
80
81
|
|
|
@@ -369,7 +370,7 @@ export default defineSite({
|
|
|
369
370
|
(caracms.config.ts), but CARACMS_CLOUD_KEY isn't set. Make a key for it in
|
|
370
371
|
app.caracms.com." (the build fails; a running server's `/admin` answers 503 with it, and
|
|
371
372
|
the public site keeps serving). It never falls back to the self-hosted variables.
|
|
372
|
-
`astro dev`
|
|
373
|
+
`astro dev` is local mode, with or without the key (see [working locally](#working-locally)).
|
|
373
374
|
- **`'self-hosted'`**: the site's own variables, as [the variables](#the-variables) list
|
|
374
375
|
them. A `CARACMS_CLOUD_KEY` beside them is ignored the same way.
|
|
375
376
|
- **Not set** (a site from before caracms 0.8.0): the key decides, and setting it beside
|
|
@@ -538,11 +539,13 @@ has been replaced.
|
|
|
538
539
|
laptop, a colleague's, a client's developer, a staging deploy. Each gets **its own key**
|
|
539
540
|
for the site, labelled ("Rich's laptop", "staging") in app.caracms.com, so revoking one
|
|
540
541
|
never touches the live site. Leave `CARACMS_CLOUD_URL` unset: images render from
|
|
541
|
-
`img.caracms.cloud` and the library lists the site's real folder.
|
|
542
|
-
|
|
542
|
+
`img.caracms.cloud` and the library lists the site's real folder. `astro dev` stays in
|
|
543
|
+
local mode, editing the files on disk: live mode on a laptop never points at the site's
|
|
544
|
+
real repo, so the key alone never turns it on (the App's variables still do). Uploads
|
|
543
545
|
from a laptop land in the real folder and count towards its storage, which is harmless:
|
|
544
546
|
nothing is overwritten or deleted, and nothing is live until it's published.
|
|
545
|
-
`CARACMS_CLOUD_URL` is only for working on CaraCMS Cloud itself, against its dev deployment
|
|
547
|
+
`CARACMS_CLOUD_URL` is only for working on CaraCMS Cloud itself, against its dev deployment;
|
|
548
|
+
with it, `astro dev` is live on that deployment's throwaway repos.
|
|
546
549
|
|
|
547
550
|
### Behind Cloudflare
|
|
548
551
|
|
package/bin/init.mjs
CHANGED
|
@@ -19,20 +19,23 @@
|
|
|
19
19
|
// left as they are on a site that already has the file (runs-anywhere Decision 12).
|
|
20
20
|
// Otherwise init writes no host file and prints the variables any host needs
|
|
21
21
|
// (HOST_VARIABLES), on the dry run and on `--write`.
|
|
22
|
-
// - Run again, it
|
|
22
|
+
// - Run again, it refreshes the skills (copies, skills.mjs), the CLAUDE.md block and the schemas, and
|
|
23
23
|
// reports the rest as already done.
|
|
24
24
|
//
|
|
25
|
-
// Plain JavaScript importing only Node built-ins and
|
|
25
|
+
// Plain JavaScript importing only Node built-ins, media.mjs and skills.mjs (and, for the schemas and the
|
|
26
26
|
// config's `services`, the site's own Vite through site-runner.mjs), so it runs on Node
|
|
27
27
|
// 22.12 with no flags.
|
|
28
28
|
|
|
29
29
|
import { execFileSync } from 'node:child_process';
|
|
30
|
-
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync,
|
|
30
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
31
31
|
import { createRequire } from 'node:module';
|
|
32
32
|
import { createInterface } from 'node:readline/promises';
|
|
33
33
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
34
34
|
import { fileURLToPath } from 'node:url';
|
|
35
35
|
import { DEFAULT_CLOUD_URL, OWN_MEDIA_NAMES, parseDotenv, REPLACED_NAMES } from './media.mjs';
|
|
36
|
+
import { skillNames, skillsPlan } from './skills.mjs';
|
|
37
|
+
|
|
38
|
+
export { skillNames };
|
|
36
39
|
|
|
37
40
|
const packageRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
38
41
|
const templates = fileURLToPath(new URL('./templates/', import.meta.url));
|
|
@@ -86,8 +89,6 @@ export const VERSIONS = { node: '^11.1.7', railway: '^3.16.0' };
|
|
|
86
89
|
|
|
87
90
|
export const MARK_START = '<!-- caracms:start -->';
|
|
88
91
|
export const MARK_END = '<!-- caracms:end -->';
|
|
89
|
-
/** Where a site's skills link to: the installed package (Decision 12). */
|
|
90
|
-
const SKILLS_LINK_BASE = '../../node_modules/caracms/skills/';
|
|
91
92
|
/** The URLs the package reserves (src/slug.ts → addressProblem). */
|
|
92
93
|
const RESERVED = ['/admin', '/_caracms', '/files'];
|
|
93
94
|
const ROUTE_EXTENSIONS = ['.astro', '.md', '.mdx', '.markdown', '.html', '.ts', '.js', '.mjs', '.mts'];
|
|
@@ -562,8 +563,8 @@ export async function askMode({ input = process.stdin, output = process.stdout }
|
|
|
562
563
|
// ── The changes ─────────────────────────────────────────────────────────────────
|
|
563
564
|
|
|
564
565
|
/**
|
|
565
|
-
* Everything init would do, as a list of changes ({ kind: 'add' | 'edit' |
|
|
566
|
-
* 'unlink' | 'schemas', file, before?, after?,
|
|
566
|
+
* Everything init would do, as a list of changes ({ kind: 'add' | 'edit' |
|
|
567
|
+
* 'remove' | 'unlink' | 'schemas', file, before?, after?, why?, skill? }), notes (printed, nothing written),
|
|
567
568
|
* and what's already done.
|
|
568
569
|
*/
|
|
569
570
|
function changesOf(site, { railway = false, mode = null, field = null } = {}) {
|
|
@@ -662,24 +663,13 @@ function changesOf(site, { railway = false, mode = null, field = null } = {}) {
|
|
|
662
663
|
notes.push(railwayNeeds(railwayFile, mode));
|
|
663
664
|
} else done.push(railwayFile);
|
|
664
665
|
|
|
665
|
-
// Skills (Decision 12
|
|
666
|
+
// Skills (Decision 12; copies since 0.9.0, skills.mjs).
|
|
666
667
|
const skills = skillNames();
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
if (!st) changes.push({ kind: 'link', file, target });
|
|
673
|
-
else if (st.isSymbolicLink() && readlinkSync(join(root, file)) === target) done.push(file);
|
|
674
|
-
else if (st.isSymbolicLink() && readlinkSync(join(root, file)).startsWith(SKILLS_LINK_BASE)) changes.push({ kind: 'link', file, target, replace: true });
|
|
675
|
-
else notes.push(`${file} is the site's own, so init doesn't link caracms's ${name} skill over it.`);
|
|
676
|
-
}
|
|
677
|
-
if (isDir(skillsDir)) {
|
|
678
|
-
for (const name of readdirSync(skillsDir).sort()) {
|
|
679
|
-
const path = join(skillsDir, name);
|
|
680
|
-
if (skills.includes(name) || !lstat(path)?.isSymbolicLink()) continue;
|
|
681
|
-
if (readlinkSync(path).startsWith(SKILLS_LINK_BASE)) changes.push({ kind: 'unlink', file: `.claude/skills/${name}` });
|
|
682
|
-
}
|
|
668
|
+
{
|
|
669
|
+
const s = skillsPlan(root);
|
|
670
|
+
changes.push(...s.changes);
|
|
671
|
+
notes.push(...s.notes);
|
|
672
|
+
done.push(...s.done);
|
|
683
673
|
}
|
|
684
674
|
|
|
685
675
|
// The CLAUDE.md block (Decision 12): into the link's target when CLAUDE.md is a link.
|
|
@@ -692,12 +682,59 @@ function changesOf(site, { railway = false, mode = null, field = null } = {}) {
|
|
|
692
682
|
if (after === null) notes.push(`${file} has a ${MARK_START} line with no ${MARK_END} after it, so init can't tell where its block ends and leaves the file alone. Replace the old block with this one by hand, then run init again:\n${block.split('\n').map((l) => ` ${l}`).join('\n')}`);
|
|
693
683
|
else edit(file, before, after, `${file} (the caracms block)`);
|
|
694
684
|
}
|
|
685
|
+
// pnpm 12.11+ asked to approve the skills caracms shipped before 0.9.0; approving wrote `permissions:
|
|
686
|
+
// caracms: skills: true`, which fails an install that runs before the repo is copied in (Railway's,
|
|
687
|
+
// Urban Return 2026-10-28). The package no longer ships skills pnpm links, so it's taken back out.
|
|
688
|
+
{
|
|
689
|
+
const before = read(join(root, 'pnpm-workspace.yaml'));
|
|
690
|
+
const after = before === null ? null : withoutSkillsApproval(before);
|
|
691
|
+
if (after === undefined) notes.push(`pnpm-workspace.yaml approves caracms's agent skills in a shape init can't edit. Take the approval out by hand (the \`skills: true\` under \`permissions:\` → \`caracms:\`): it fails an install that runs before the repo is copied in, Railway's for one, and init copies the skills in itself.`);
|
|
692
|
+
else if (after !== null && after !== before) {
|
|
693
|
+
changes.push({ kind: 'edit', file: 'pnpm-workspace.yaml', before, after });
|
|
694
|
+
notes.push(`pnpm-workspace.yaml approved caracms's agent skills (\`pnpm approve\`), so init takes that out: the approval fails an install that runs before the repo is copied in, Railway's for one, and init copies the skills in itself. pnpm won't ask about caracms again.`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
695
697
|
// MEC trial item 1: what Tina left behind, untracked (tinaOf).
|
|
696
698
|
const { leftover } = tinaOf(site);
|
|
697
699
|
if (leftover) notes.push(`${leftover.length ? leftover.join(', ') : 'tina/'} ${leftover.length > 1 ? 'are' : 'is'} left over from Tina (nothing under tina/ is tracked): delete ${leftover.length > 1 ? 'them' : 'it'}.`);
|
|
698
700
|
return { changes, notes, done, hostVariables: !railway && !hasRailway };
|
|
699
701
|
}
|
|
700
702
|
|
|
703
|
+
/**
|
|
704
|
+
* pnpm-workspace.yaml's text without the approval of caracms's agent skills (`permissions:` →
|
|
705
|
+
* `caracms:` → `skills: true`), emptied parents and all; the same text when there's none.
|
|
706
|
+
* Undefined when there is one in a shape these lines can't follow (a flow mapping, say).
|
|
707
|
+
*/
|
|
708
|
+
export function withoutSkillsApproval(text) {
|
|
709
|
+
const lines = text.split('\n');
|
|
710
|
+
const indent = (l) => /^ */.exec(l)[0].length;
|
|
711
|
+
const blank = (l) => /^\s*(#.*)?$/.test(l);
|
|
712
|
+
// The lines under `lines[at]`: everything indented deeper, up to the next line that isn't.
|
|
713
|
+
const childrenOf = (at) => {
|
|
714
|
+
const out = [];
|
|
715
|
+
for (let i = at + 1; i < lines.length; i++) {
|
|
716
|
+
if (blank(lines[i])) continue;
|
|
717
|
+
if (indent(lines[i]) <= indent(lines[at])) break;
|
|
718
|
+
out.push(i);
|
|
719
|
+
}
|
|
720
|
+
return out;
|
|
721
|
+
};
|
|
722
|
+
const top = lines.findIndex((l) => /^permissions:\s*(#.*)?$/.test(l));
|
|
723
|
+
if (top === -1) return /^permissions:/m.test(text) && /caracms/.test(text) ? undefined : text;
|
|
724
|
+
const under = childrenOf(top);
|
|
725
|
+
const pkg = under.find((i) => /^\s+(['"]?)caracms\1:/.test(lines[i]));
|
|
726
|
+
if (pkg === undefined) return text;
|
|
727
|
+
const rest = lines[pkg].replace(/^\s+(['"]?)caracms\1:/, '').replace(/#.*$/, '').trim();
|
|
728
|
+
if (rest) return /skills\s*:\s*true/.test(rest) ? undefined : text;
|
|
729
|
+
const own = childrenOf(pkg);
|
|
730
|
+
const skills = own.find((i) => /^\s+skills:\s*true\s*(#.*)?$/.test(lines[i]));
|
|
731
|
+
if (skills === undefined) return /skills\s*:\s*true/.test(own.map((i) => lines[i]).join('\n')) ? undefined : text;
|
|
732
|
+
const drop = new Set([skills]);
|
|
733
|
+
if (own.length === 1) drop.add(pkg);
|
|
734
|
+
if (own.length === 1 && under.every((i) => i === pkg || own.includes(i))) drop.add(top);
|
|
735
|
+
return lines.filter((_, i) => !drop.has(i)).join('\n');
|
|
736
|
+
}
|
|
737
|
+
|
|
701
738
|
/** `object` with `key` added, in alphabetical order when the object already is. */
|
|
702
739
|
function withKey(object, key, value) {
|
|
703
740
|
if (Object.hasOwn(object, key)) return object;
|
|
@@ -810,13 +847,6 @@ function railwayNeeds(file, mode = null) {
|
|
|
810
847
|
].join('\n');
|
|
811
848
|
}
|
|
812
849
|
|
|
813
|
-
/** The package's skills: its `skills/<name>/` folders. */
|
|
814
|
-
export function skillNames() {
|
|
815
|
-
const dir = join(packageRoot, 'skills');
|
|
816
|
-
if (!isDir(dir)) return [];
|
|
817
|
-
return readdirSync(dir).filter((n) => !n.startsWith('.') && isDir(join(dir, n))).sort();
|
|
818
|
-
}
|
|
819
|
-
|
|
820
850
|
/**
|
|
821
851
|
* @param {string[]} skills
|
|
822
852
|
* @param {string | null} [mode] the config's `services`; null: not set
|
|
@@ -828,7 +858,7 @@ export function claudeBlock(skills, mode = null) {
|
|
|
828
858
|
'',
|
|
829
859
|
MODE_LINES[mode ?? 'none'],
|
|
830
860
|
'',
|
|
831
|
-
"This site's content is edited with caracms (the `caracms` package). Its skills are
|
|
861
|
+
"This site's content is edited with caracms (the `caracms` package). Its skills are copied from the installed package into `.claude/skills/` by `caracms init`, which brings them up to date after an upgrade (`astro dev` and `caracms validate` say when they're behind):",
|
|
832
862
|
'',
|
|
833
863
|
...skills.map((name) => `- \`${name}\`${SKILL_BLURBS[name] ? `: ${SKILL_BLURBS[name]}.` : ''}`),
|
|
834
864
|
...(skills.length ? [''] : []),
|
|
@@ -893,10 +923,10 @@ function describe(change) {
|
|
|
893
923
|
return ` add ${change.file}`;
|
|
894
924
|
case 'edit':
|
|
895
925
|
return ` edit ${change.file}`;
|
|
896
|
-
case '
|
|
897
|
-
return `
|
|
926
|
+
case 'remove':
|
|
927
|
+
return ` remove ${change.file} (${change.why})`;
|
|
898
928
|
case 'unlink':
|
|
899
|
-
return ` unlink ${change.file} (
|
|
929
|
+
return ` unlink ${change.file} (${change.why})`;
|
|
900
930
|
case 'schemas':
|
|
901
931
|
return ` ${change.before === null ? 'add ' : change.after === null ? 'remove' : 'edit '} ${change.file}${change.generated ? ' (generated: caracms schemas)' : ''}`;
|
|
902
932
|
}
|
|
@@ -911,7 +941,7 @@ function report({ changes, notes, done, hostVariables, mode, where, site }, { wr
|
|
|
911
941
|
parts.push(write ? 'caracms init: done. It changed:' : 'caracms init: the change it would make. Nothing is written: `caracms init --write` applies exactly this.');
|
|
912
942
|
parts.push(changes.map(describe).join('\n'));
|
|
913
943
|
if (!write) {
|
|
914
|
-
const diffs = changes.filter((c) => (c.kind === 'add' || c.kind === 'edit' || (c.kind === 'schemas' && !c.generated && c.after !== null)));
|
|
944
|
+
const diffs = changes.filter((c) => (((c.kind === 'add' || c.kind === 'edit') && !c.skill) || (c.kind === 'schemas' && !c.generated && c.after !== null)));
|
|
915
945
|
if (diffs.length) parts.push(diffs.map((c) => diff(c.file, c.before ?? null, c.after)).join('\n\n'));
|
|
916
946
|
}
|
|
917
947
|
}
|
|
@@ -1008,13 +1038,11 @@ function apply(root, changes) {
|
|
|
1008
1038
|
mkdirSync(dirname(path), { recursive: true });
|
|
1009
1039
|
writeFileSync(path, c.after);
|
|
1010
1040
|
break;
|
|
1011
|
-
case '
|
|
1012
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
1013
|
-
if (c.replace) rmSync(path, { force: true });
|
|
1014
|
-
symlinkSync(c.target, path);
|
|
1015
|
-
break;
|
|
1041
|
+
case 'remove':
|
|
1016
1042
|
case 'unlink':
|
|
1017
1043
|
rmSync(path, { force: true });
|
|
1044
|
+
// A skill folder left empty goes too (skills.mjs: a skill the package no longer has).
|
|
1045
|
+
for (let dir = dirname(path); c.kind === 'remove' && dir.startsWith(join(root, '.claude/skills/')) && readdirSync(dir).length === 0; dir = dirname(dir)) rmSync(dir, { recursive: true });
|
|
1018
1046
|
break;
|
|
1019
1047
|
// 'schemas' is written by the schemas command, after everything else.
|
|
1020
1048
|
}
|
package/bin/skills.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// The skills caracms puts in a site: the package's `site-skills/<name>/` folders, copied into the
|
|
2
|
+
// site's `.claude/skills/<name>/` by `caracms init`, with a stamp of what was copied
|
|
3
|
+
// (`.claude/skills/.caracms.json`: each file's hash). Copies, not links (0.9.0): pnpm 12.11+ asks
|
|
4
|
+
// to approve any package that ships `skills/<name>/SKILL.md`, and an approval fails an install
|
|
5
|
+
// that runs before the repo is copied in (Railway's). So the package keeps them in `site-skills/`,
|
|
6
|
+
// which pnpm doesn't look in, and a site's copies work on a fresh clone with nothing installed.
|
|
7
|
+
//
|
|
8
|
+
// The stamp is how init tells a copy it may refresh from one someone edited: a file whose hash
|
|
9
|
+
// is still the stamp's is caracms's, and is brought up to date; one that isn't is left alone,
|
|
10
|
+
// with a note. `astro dev` and `caracms validate` say when the copies are behind (skillsWarning).
|
|
11
|
+
//
|
|
12
|
+
// Plain JavaScript importing only Node built-ins: init.mjs, validate and the integration share it.
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { lstatSync, readdirSync, readFileSync, readlinkSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const packageRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
20
|
+
const source = join(packageRoot, 'site-skills');
|
|
21
|
+
export const SKILLS_DIR = '.claude/skills';
|
|
22
|
+
export const STAMP = `${SKILLS_DIR}/.caracms.json`;
|
|
23
|
+
/** Where 0.8 and earlier linked a site's skills to. */
|
|
24
|
+
export const OLD_LINK_BASE = '../../node_modules/caracms/skills/';
|
|
25
|
+
|
|
26
|
+
const hash = (text) => createHash('sha256').update(text).digest('hex');
|
|
27
|
+
const lstat = (path) => lstatSync(path, { throwIfNoEntry: false }) ?? null;
|
|
28
|
+
const read = (path) => {
|
|
29
|
+
try {
|
|
30
|
+
return readFileSync(path, 'utf8');
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The package's skills: its `site-skills/<name>/` folders. */
|
|
37
|
+
export function skillNames() {
|
|
38
|
+
if (!lstat(source)?.isDirectory()) return [];
|
|
39
|
+
return readdirSync(source, { withFileTypes: true })
|
|
40
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
|
41
|
+
.map((d) => d.name)
|
|
42
|
+
.sort();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The files under `dir`, as `/`-separated paths relative to it, sorted. */
|
|
46
|
+
function filesUnder(dir, prefix = '') {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) {
|
|
49
|
+
if (d.name.startsWith('.')) continue;
|
|
50
|
+
const rel = `${prefix}${d.name}`;
|
|
51
|
+
if (d.isDirectory()) out.push(...filesUnder(join(dir, d.name), `${rel}/`));
|
|
52
|
+
else if (d.isFile()) out.push(rel);
|
|
53
|
+
}
|
|
54
|
+
return out.sort();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The package's skill files: `<name>/<file>` → text. */
|
|
58
|
+
function packageFiles() {
|
|
59
|
+
const out = new Map();
|
|
60
|
+
for (const name of skillNames()) for (const file of filesUnder(join(source, name))) out.set(`${name}/${file}`, readFileSync(join(source, name, file), 'utf8'));
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The stamp's hashes (`<name>/<file>` → hash), or null when there's no stamp or it doesn't parse. */
|
|
65
|
+
function stampOf(root) {
|
|
66
|
+
try {
|
|
67
|
+
const files = JSON.parse(read(join(root, STAMP)) ?? '').files;
|
|
68
|
+
return files && typeof files === 'object' ? files : null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What init does to bring a site's `.claude/skills/` to the package's skills, as init's changes
|
|
76
|
+
* (`add`, `edit`, `remove` and `unlink`; a skill file's carry `skill: true`, so a dry run lists
|
|
77
|
+
* them without diffs), notes and what's already done.
|
|
78
|
+
* @param {string} root
|
|
79
|
+
*/
|
|
80
|
+
export function skillsPlan(root) {
|
|
81
|
+
const changes = [];
|
|
82
|
+
const notes = [];
|
|
83
|
+
const done = [];
|
|
84
|
+
const wanted = packageFiles();
|
|
85
|
+
const stamp = stampOf(root) ?? {};
|
|
86
|
+
const next = {};
|
|
87
|
+
const at = (rel) => `${SKILLS_DIR}/${rel}`;
|
|
88
|
+
const mine = (rel, text) => stamp[rel] !== undefined && hash(text) === stamp[rel];
|
|
89
|
+
|
|
90
|
+
for (const name of skillNames()) {
|
|
91
|
+
const dir = at(name);
|
|
92
|
+
const st = lstat(join(root, dir));
|
|
93
|
+
const files = [...wanted.keys()].filter((rel) => rel.startsWith(`${name}/`));
|
|
94
|
+
const fresh = () => {
|
|
95
|
+
for (const rel of files) {
|
|
96
|
+
changes.push({ kind: 'add', file: at(rel), after: wanted.get(rel), skill: true });
|
|
97
|
+
next[rel] = hash(wanted.get(rel));
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
if (st?.isSymbolicLink()) {
|
|
101
|
+
if (!readlinkSync(join(root, dir)).startsWith(OLD_LINK_BASE)) {
|
|
102
|
+
notes.push(`${dir} is the site's own (a link elsewhere), so init doesn't copy caracms's ${name} skill over it.`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// 0.8 and earlier: a link into node_modules, now a copy.
|
|
106
|
+
changes.push({ kind: 'unlink', file: dir, why: 'a link into node_modules, now a copy' });
|
|
107
|
+
fresh();
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!st) {
|
|
111
|
+
fresh();
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Caracms's when the stamp names it, or when every file is caracms's word for word (a lost stamp).
|
|
115
|
+
const ours = Object.keys(stamp).some((rel) => rel.startsWith(`${name}/`)) || (st.isDirectory() && files.every((rel) => read(join(root, at(rel))) === wanted.get(rel)));
|
|
116
|
+
if (!st.isDirectory() || !ours) {
|
|
117
|
+
notes.push(`${dir} is the site's own, so init doesn't copy caracms's ${name} skill over it.`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
let current = true;
|
|
121
|
+
for (const rel of files) {
|
|
122
|
+
const want = wanted.get(rel);
|
|
123
|
+
const have = read(join(root, at(rel)));
|
|
124
|
+
if (have === want) next[rel] = hash(want);
|
|
125
|
+
else if (have === null || mine(rel, have)) {
|
|
126
|
+
changes.push({ kind: have === null ? 'add' : 'edit', file: at(rel), before: have, after: want, skill: true });
|
|
127
|
+
next[rel] = hash(want);
|
|
128
|
+
current = false;
|
|
129
|
+
} else {
|
|
130
|
+
notes.push(`${at(rel)} has been edited here, so init leaves it as it is. caracms's own is node_modules/caracms/site-skills/${rel}: bring your changes across, or delete the file and run init again.`);
|
|
131
|
+
if (stamp[rel] !== undefined) next[rel] = stamp[rel];
|
|
132
|
+
current = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Files caracms copied that the skill no longer has.
|
|
136
|
+
for (const rel of Object.keys(stamp).filter((r) => r.startsWith(`${name}/`) && !wanted.has(r))) {
|
|
137
|
+
const have = read(join(root, at(rel)));
|
|
138
|
+
if (have === null) continue;
|
|
139
|
+
if (mine(rel, have)) changes.push({ kind: 'remove', file: at(rel), why: 'no longer in the skill' });
|
|
140
|
+
else {
|
|
141
|
+
notes.push(`${at(rel)} is no longer in caracms's ${name} skill, but it has been edited here, so init leaves it.`);
|
|
142
|
+
next[rel] = stamp[rel];
|
|
143
|
+
}
|
|
144
|
+
current = false;
|
|
145
|
+
}
|
|
146
|
+
if (current) done.push(dir);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Skills the package no longer has: their copies (when unedited) and their 0.8 links.
|
|
150
|
+
const names = new Set(skillNames());
|
|
151
|
+
for (const rel of Object.keys(stamp).filter((r) => !names.has(r.split('/')[0]))) {
|
|
152
|
+
const have = read(join(root, at(rel)));
|
|
153
|
+
if (have === null) continue;
|
|
154
|
+
if (mine(rel, have)) changes.push({ kind: 'remove', file: at(rel), why: 'that skill is no longer in the package' });
|
|
155
|
+
else {
|
|
156
|
+
notes.push(`${at(rel)} is from a skill caracms no longer has, but it has been edited here, so init leaves it.`);
|
|
157
|
+
next[rel] = stamp[rel];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const dirPath = join(root, SKILLS_DIR);
|
|
161
|
+
if (lstat(dirPath)?.isDirectory()) {
|
|
162
|
+
for (const name of readdirSync(dirPath).sort()) {
|
|
163
|
+
if (names.has(name) || !lstat(join(dirPath, name))?.isSymbolicLink()) continue;
|
|
164
|
+
if (readlinkSync(join(dirPath, name)).startsWith(OLD_LINK_BASE)) changes.push({ kind: 'unlink', file: at(name), why: 'that skill is no longer in the package' });
|
|
165
|
+
// pnpm's own links, from an approval of 0.8's skills (gitignored, so only on that machine): each skill twice.
|
|
166
|
+
else if (name.startsWith('pnpm-caracms-')) changes.push({ kind: 'unlink', file: at(name), why: "pnpm's link from an approval, a second copy of a skill" });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const before = read(join(root, STAMP));
|
|
171
|
+
const after = `${JSON.stringify({ about: "caracms's skills as `caracms init` copied them: it refreshes a file whose hash is still this one, and leaves an edited one alone.", files: next }, null, 2)}\n`;
|
|
172
|
+
if (before !== after && (changes.length || Object.keys(next).length)) changes.push({ kind: before === null ? 'add' : 'edit', file: STAMP, before, after, skill: true });
|
|
173
|
+
return { changes, notes, done };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One sentence when the site's copies are behind the installed package (what `astro dev` and
|
|
178
|
+
* `caracms validate` warn), else null. Only for a site that has caracms's skills already (a
|
|
179
|
+
* stamp, or 0.8's links): one that never ran init, or took them out, gets nothing.
|
|
180
|
+
* @param {string} root
|
|
181
|
+
* @returns {string | null}
|
|
182
|
+
*/
|
|
183
|
+
export function skillsWarning(root) {
|
|
184
|
+
const linked = (() => {
|
|
185
|
+
const dirPath = join(root, SKILLS_DIR);
|
|
186
|
+
if (!lstat(dirPath)?.isDirectory()) return false;
|
|
187
|
+
return readdirSync(dirPath).some((n) => lstat(join(dirPath, n))?.isSymbolicLink() && readlinkSync(join(dirPath, n)).startsWith(OLD_LINK_BASE));
|
|
188
|
+
})();
|
|
189
|
+
if (!linked && stampOf(root) === null) return null;
|
|
190
|
+
const behind = skillsPlan(root).changes.some((c) => c.file !== STAMP);
|
|
191
|
+
if (!behind) return null;
|
|
192
|
+
const version = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
193
|
+
return linked
|
|
194
|
+
? `${SKILLS_DIR} links caracms's skills from node_modules, and caracms ${version} copies them in instead: run \`npx caracms init --write\` and commit the result.`
|
|
195
|
+
: `${SKILLS_DIR} is behind the skills of the installed caracms (${version}): run \`npx caracms init --write\` and commit the result.`;
|
|
196
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "caracms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "A visual CMS for Astro sites, with content in git.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"files": [
|
|
27
27
|
"src",
|
|
28
28
|
"bin",
|
|
29
|
-
"skills",
|
|
29
|
+
"site-skills",
|
|
30
30
|
"README.md",
|
|
31
31
|
"LICENSE"
|
|
32
32
|
],
|
|
@@ -33,8 +33,8 @@ Start from a clean git checkout (untracked files are fine). The order is:
|
|
|
33
33
|
**`npx caracms init --cloud`** (or `--self-hosted`) without `--write` is a dry run: it
|
|
34
34
|
writes nothing and prints
|
|
35
35
|
|
|
36
|
-
- one line per file: `add` (a new file), `edit` (an existing one), `
|
|
37
|
-
skill link in `.claude/skills/`);
|
|
36
|
+
- one line per file: `add` (a new file), `edit` (an existing one), `remove` / `unlink` (a
|
|
37
|
+
skill file or link in `.claude/skills/` that goes);
|
|
38
38
|
- a diff of each;
|
|
39
39
|
- `Already done: …` on a site partly set up;
|
|
40
40
|
- `Notes:`, things it can't do itself, with the lines to add by hand (an `astro.config` it
|
|
@@ -56,11 +56,12 @@ to tracked files: usually the install of step 1, not yet committed.
|
|
|
56
56
|
Read the diff. Then **`npx caracms init --cloud --write`** (or `--self-hosted --write`),
|
|
57
57
|
install again (`npm install` / `pnpm install`), and commit that as one change.
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
caracms's skills are copied into `.claude/skills/` by `init`, as real files committed with
|
|
60
|
+
the site, so they work on a fresh clone with nothing installed. pnpm doesn't ask to approve
|
|
61
|
+
them (since 0.9.0 the package keeps them where pnpm doesn't look). A site that approved
|
|
62
|
+
0.8's has `permissions: caracms: skills: true` in `pnpm-workspace.yaml`, which fails an
|
|
63
|
+
install that runs before the repo is copied in (Railway's, `ERR_PNPM_NO_AGENT_SKILLS_DIR`):
|
|
64
|
+
`init --write` takes it out.
|
|
64
65
|
|
|
65
66
|
`init` added:
|
|
66
67
|
|
|
@@ -76,7 +77,7 @@ unapproved, and don't approve it in passing (`pnpm approve` also reviews build s
|
|
|
76
77
|
`devToolbar: { enabled: false }`;
|
|
77
78
|
- `package.json`: `@astrojs/node`, `"start": "caracms start"`;
|
|
78
79
|
`.gitignore`: `.caracms/`; the tsconfig's `allowImportingTsExtensions`;
|
|
79
|
-
- the
|
|
80
|
+
- the skills, copied into `.claude/skills/` with a stamp (`.caracms.json`) of what it copied, and a block in `CLAUDE.md`;
|
|
80
81
|
- with `--railway` only, `.railway/railway.ts` (when the site had none) and `railway`
|
|
81
82
|
(dev). Without it, `init` prints the variables any host needs instead. Where and how
|
|
82
83
|
the site runs (Railway, Render, Fly, a VPS) is `caracms-hosting` → section 2.
|
|
@@ -87,8 +88,8 @@ the output moves to `dist/client/` and `dist/server/`, and `npm start` is now
|
|
|
87
88
|
(its own built-site checks, a script) needs the new paths: the pages are in
|
|
88
89
|
`dist/client/`.
|
|
89
90
|
|
|
90
|
-
Re-run `init` after updating caracms: it reads the mode from the config,
|
|
91
|
-
skills
|
|
91
|
+
Re-run `init` after updating caracms: it reads the mode from the config, refreshes the
|
|
92
|
+
skills (leaving any file edited here alone, with a note), the CLAUDE.md block and the schemas. On a set-up site with nothing
|
|
92
93
|
to do it says
|
|
93
94
|
`caracms init: nothing to do. caracms is set up here.`
|
|
94
95
|
|
|
@@ -115,7 +116,7 @@ is the exception (section 4).
|
|
|
115
116
|
**Images need credentials** in the site's `.env` (never committed). On CaraCMS Cloud
|
|
116
117
|
that's `CARACMS_CLOUD_KEY` alone: a key labelled for this laptop, made in
|
|
117
118
|
app.caracms.com (`caracms-hosting` → section 1); it shows, imports and uploads images,
|
|
118
|
-
and
|
|
119
|
+
and `astro dev` still edits the files on disk. Self-hosted, it's the media
|
|
119
120
|
variables: `CARACMS_MEDIA_URL` (imgproxy's origin), `IMGPROXY_KEY`, `IMGPROXY_SALT` and
|
|
120
121
|
`S3_BUCKET` to show images, plus `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY_ID` and
|
|
121
122
|
`S3_SECRET_ACCESS_KEY` to import and upload them. Without them `astro dev` draws a
|
|
@@ -81,8 +81,9 @@ npx caracms init --cloud --write # writes services: 'cloud' and the rest
|
|
|
81
81
|
**One key per place the site runs**, each labelled in app.caracms.com: "Railway" for
|
|
82
82
|
the host, "Rich's laptop" for a laptop's `.env`, "staging" for a staging deploy.
|
|
83
83
|
Revoking one never touches the others. Leave `CARACMS_CLOUD_URL` unset (it's only for
|
|
84
|
-
working on CaraCMS Cloud itself). On a laptop
|
|
85
|
-
|
|
84
|
+
working on CaraCMS Cloud itself). On a laptop `astro dev` edits content on disk, with or
|
|
85
|
+
without a key; the key shows, imports and uploads images in the site's real folder. Only
|
|
86
|
+
the key with `CARACMS_CLOUD_URL` (the cloud's dev deployment) makes `astro dev` live.
|
|
86
87
|
|
|
87
88
|
**A production build needs the key.** With `services: 'cloud'` and no
|
|
88
89
|
`CARACMS_CLOUD_KEY`, `astro build` (and `pnpm build` on a laptop, which is a production
|
|
@@ -91,7 +91,7 @@ Work on a branch (`caracms-port`, say) in a worktree of the site. Commit after e
|
|
|
91
91
|
|
|
92
92
|
## 2. The conversion script
|
|
93
93
|
|
|
94
|
-
Copy
|
|
94
|
+
Copy `.claude/skills/caracms-tina/tina-to-caracms.mjs` into the site's own
|
|
95
95
|
`scripts/`. The site's copy is the one that runs; it's committed with the site.
|
|
96
96
|
|
|
97
97
|
**Adapt only the table under `ADAPT HERE`** (the script's header says what each entry is):
|
package/src/cli/validate.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { sameAddress } from '../slug.ts';
|
|
|
41
41
|
import { hiddenDocuments, hiddenPages } from '../links.ts';
|
|
42
42
|
import { runSchemas, schemaFileChanges, schemasReport } from './schemas.ts';
|
|
43
43
|
import { jsonFilesUnder, loadSite } from './site.ts';
|
|
44
|
+
import { skillsWarning } from '../../bin/skills.mjs';
|
|
44
45
|
|
|
45
46
|
export interface Finding {
|
|
46
47
|
/** Relative to the site's root. */
|
|
@@ -475,6 +476,9 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
|
|
|
475
476
|
message: change.text === null ? 'The config no longer makes this schema. `caracms schemas` removes it.' : change.before === null ? 'This schema file is missing. `caracms schemas` writes it.' : "This schema file is out of date with caracms.config.ts. `caracms schemas` rewrites it.",
|
|
476
477
|
});
|
|
477
478
|
}
|
|
479
|
+
// The skills init copied in (bin/skills.mjs), when the installed caracms has newer ones. A warning: old docs never fail a pull request.
|
|
480
|
+
const skills = skillsWarning(site.root);
|
|
481
|
+
if (skills) report.warnings.push({ file: '.claude/skills', path: '', message: skills });
|
|
478
482
|
return report;
|
|
479
483
|
}
|
|
480
484
|
|
package/src/index.ts
CHANGED
|
@@ -58,6 +58,7 @@ import { resolveSite, SITE_SETTINGS_FILE, type ResolvedSite, type SiteConfig } f
|
|
|
58
58
|
import { lockupCss } from './lockup-css.ts';
|
|
59
59
|
import { shareBuildReads } from './read.ts';
|
|
60
60
|
import { writeSchemaFiles } from './cli/schemas.ts';
|
|
61
|
+
import { skillsWarning } from '../bin/skills.mjs';
|
|
61
62
|
import { configFile } from './cli/site.ts';
|
|
62
63
|
import { pagesRoutes } from './code-routes-disk.ts';
|
|
63
64
|
import { codeRoutesFrom } from './code-routes.ts';
|
|
@@ -523,6 +524,13 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
523
524
|
} catch (error) {
|
|
524
525
|
logger.warn(`couldn't write schemas/: ${(error as Error).message}`);
|
|
525
526
|
}
|
|
527
|
+
// The skills init copied in (bin/skills.mjs): one line when the installed caracms has newer ones.
|
|
528
|
+
try {
|
|
529
|
+
const skills = skillsWarning(site.root);
|
|
530
|
+
if (skills) logger.warn(skills);
|
|
531
|
+
} catch {
|
|
532
|
+
// A warning about docs never stops the dev server.
|
|
533
|
+
}
|
|
526
534
|
// A content change re-runs every route's getStaticPaths, as Astro does for its own content
|
|
527
535
|
// collections (the same event, to the same server environments). Never a browser reload:
|
|
528
536
|
// the editor writes these files.
|
package/src/live/mode.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which mode the editor runs in, read from the environment (M4 Decision 1).
|
|
3
3
|
*
|
|
4
|
-
* - **Local mode**: `astro dev` with no GitHub credentials (`
|
|
5
|
-
* `
|
|
4
|
+
* - **Local mode**: `astro dev` with no GitHub credentials (`GITHUB_APP_ID` or
|
|
5
|
+
* `CARACMS_GITHUB_TOKEN`). M3's editor, files on disk. `CARACMS_CLOUD_KEY` alone stays
|
|
6
|
+
* local (caracms-cloud: live mode on a laptop never points at a client's repo); the key
|
|
7
|
+
* is still the images', clips' and forms' credential there. Only the key with
|
|
8
|
+
* `CARACMS_CLOUD_URL` (the cloud's dev deployment, throwaway repos only) is live in dev.
|
|
6
9
|
* - **Live mode**: whenever a credential is configured, and **always on a production
|
|
7
10
|
* server**. It needs one GitHub credential (a fine-grained token, or the App's
|
|
8
11
|
* `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`; runs-anywhere Decisions 3–4), the repo
|
|
@@ -208,7 +211,9 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
|
|
|
208
211
|
// Never GITHUB_TOKEN (Decision 4): the gh CLI and GitHub Actions export it.
|
|
209
212
|
const ghToken = get('CARACMS_GITHUB_TOKEN');
|
|
210
213
|
const cloudKey = get('CARACMS_CLOUD_KEY');
|
|
211
|
-
|
|
214
|
+
// A laptop's key is the production cloud's, so the site's real repo: dev stays local unless CARACMS_CLOUD_URL names another cloud.
|
|
215
|
+
const devCloud = cloudKey && get('CARACMS_CLOUD_URL');
|
|
216
|
+
if (!production && !memory && !get('GITHUB_APP_ID') && !ghToken && !devCloud) return { kind: 'local' };
|
|
212
217
|
|
|
213
218
|
const missing: string[] = [];
|
|
214
219
|
const problems: string[] = [];
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|