@easysociable/cli 0.1.1 → 0.1.3

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 (116) hide show
  1. package/README.md +29 -18
  2. package/content-engine/fixtures/golden-run-en/draft.md +13 -0
  3. package/content-engine/fixtures/golden-run-en/idea.md +26 -0
  4. package/content-engine/fixtures/golden-run-en/pack-tiktok.json +11 -0
  5. package/content-engine/scripts/contracts.mjs +183 -0
  6. package/content-engine/scripts/export-contracts.mjs +76 -0
  7. package/content-engine/scripts/fixture.test.mjs +24 -0
  8. package/content-engine/scripts/starter.mjs +55 -0
  9. package/content-engine/scripts/validate-export.mjs +42 -0
  10. package/content-engine/scripts/validate-pack.mjs +48 -0
  11. package/content-engine/scripts/validate-run.mjs +100 -0
  12. package/content-engine/scripts/validate.test.mjs +101 -0
  13. package/content-engine/scripts/workbench.mjs +135 -0
  14. package/content-engine/starter/CLAUDE.md +756 -0
  15. package/content-engine/starter/accounts/README.md +9 -0
  16. package/content-engine/starter/accounts/_index.md +31 -0
  17. package/content-engine/starter/accounts/overrides/README.md +3 -0
  18. package/content-engine/starter/discovery/README.md +18 -0
  19. package/content-engine/starter/discovery/_index.md +27 -0
  20. package/content-engine/starter/discovery/_template.md +41 -0
  21. package/content-engine/starter/engine.json +19 -0
  22. package/content-engine/starter/exports/README.md +28 -0
  23. package/content-engine/starter/inbox/README.md +9 -0
  24. package/content-engine/starter/inbox/_index.md +24 -0
  25. package/content-engine/starter/inbox/entries/README.md +3 -0
  26. package/content-engine/starter/platforms/README.md +19 -0
  27. package/content-engine/starter/platforms/ig.md +69 -0
  28. package/content-engine/starter/platforms/instagram.md +5 -0
  29. package/content-engine/starter/platforms/linkedin.md +72 -0
  30. package/content-engine/starter/platforms/threads.md +69 -0
  31. package/content-engine/starter/platforms/tiktok.md +71 -0
  32. package/content-engine/starter/platforms/x.md +78 -0
  33. package/content-engine/starter/platforms/xiaohongshu.md +19 -0
  34. package/content-engine/starter/profiles/_index.md +13 -0
  35. package/content-engine/starter/profiles/default/README.md +11 -0
  36. package/content-engine/starter/profiles/default/audience.md +38 -0
  37. package/content-engine/starter/profiles/default/pillars.md +33 -0
  38. package/content-engine/starter/profiles/default/voice.md +24 -0
  39. package/content-engine/starter/published/_index.md +20 -0
  40. package/content-engine/starter/raw/README.md +4 -0
  41. package/content-engine/starter/raw/_index.md +12 -0
  42. package/content-engine/starter/runs/README.md +22 -0
  43. package/content-engine/starter/runs/_index.md +41 -0
  44. package/content-engine/starter/runs/_template/brief.md +66 -0
  45. package/content-engine/starter/runs/_template/draft.md +11 -0
  46. package/content-engine/starter/runs/_template/editor.md +18 -0
  47. package/content-engine/starter/runs/_template/feedback.md +15 -0
  48. package/content-engine/starter/runs/_template/idea.md +27 -0
  49. package/content-engine/starter/runs/_template/pack.md +47 -0
  50. package/content-engine/starter/runs/_template/packet.md +25 -0
  51. package/content-engine/starter/runs/_template/rubric.md +19 -0
  52. package/content-engine/starter/swipe/_index.md +27 -0
  53. package/content-engine/starter/swipe/_template.md +34 -0
  54. package/content-engine/starter/topics/README.md +25 -0
  55. package/content-engine/starter/topics/WATCHLIST.md +65 -0
  56. package/content-engine/starter/topics/_index.md +44 -0
  57. package/content-engine/starter/topics/_template-item.md +90 -0
  58. package/content-engine/starter/topics/items/README.md +4 -0
  59. package/content-engine/starter/topics/scoring.md +201 -0
  60. package/content-engine/starter/topics/watchlists/README.md +10 -0
  61. package/content-engine/starter/topics/watchlists/_shared.md +14 -0
  62. package/content-engine/starter/topics/watchlists/default.md +6 -0
  63. package/content-engine/starter/wiki/README.md +13 -0
  64. package/content-engine/starter/wiki/atoms/_index.md +23 -0
  65. package/content-engine/starter/wiki/atoms/_template.md +31 -0
  66. package/content-engine/starter/wiki/claims/_index.md +26 -0
  67. package/content-engine/starter/wiki/claims/_template.md +38 -0
  68. package/content-engine/starter/workbench/README.md +17 -0
  69. package/content-engine/workbench/build.py +1074 -0
  70. package/content-engine/workbench/serve.py +53 -0
  71. package/content-engine/workbench/template.html +2119 -0
  72. package/dist/easysociable.js +804 -2903
  73. package/dist/easysociable.js.map +1 -1
  74. package/package.json +19 -13
  75. package/skills/ONBOARDING.md +20 -14
  76. package/skills/easysociable-content/SKILL.md +38 -102
  77. package/skills/easysociable-slideshow/SKILL.md +24 -16
  78. package/skills/easysociable-slideshow/references/VAULT.md +13 -11
  79. package/skills/easysociable-template/SKILL.md +26 -24
  80. package/skills/easysociable-template/references/CATALOG_METADATA.md +2 -2
  81. package/skills/easysociable-template/references/DESIGN_V2_1.md +53 -2
  82. package/skills/easysociable-template/references/GATEWAY.md +7 -2
  83. package/skills/easysociable-template/references/PLATFORM_SPECS.md +11 -3
  84. package/skills/easysociable-template/references/THEME_TOKENS.md +1 -1
  85. package/skills/easysociable-template/references/fixtures/image-reference.design-plan.json +74 -0
  86. package/skills/easysociable-template/references/fixtures/image-reference.visual-qa.json +28 -0
  87. package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +0 -0
  88. package/skills/easysociable-template/scripts/validate-design-v2.mjs +97 -0
  89. package/skills/easysociable-template/workflows/ASSET_PLAN.md +2 -1
  90. package/skills/easysociable-template/workflows/AUTHORING_FROM_BRIEF.md +4 -4
  91. package/skills/easysociable-template/workflows/AUTHORING_FROM_IMAGE.md +66 -16
  92. package/skills/easysociable-template/workflows/AUTHORING_STUDIO.md +28 -15
  93. package/skills/easysociable-template/workflows/DESIGN_BRIEF.md +4 -3
  94. package/skills/easysociable-template/workflows/MULTI_PLATFORM_FAMILY.md +81 -0
  95. package/skills/easysociable-template/workflows/PUBLISH_FROM_DRAFT.md +1 -1
  96. package/skills/easysociable-template/workflows/VISUAL_QA.md +10 -0
  97. package/skills/easysociable-content/platforms/claude.md +0 -5
  98. package/skills/easysociable-content/platforms/codex.md +0 -5
  99. package/skills/easysociable-content/platforms/hermes.md +0 -5
  100. package/skills/easysociable-content/platforms/openclaw.md +0 -5
  101. package/skills/easysociable-content/references/CLI.md +0 -27
  102. package/skills/easysociable-content/references/MCP.md +0 -20
  103. package/skills/easysociable-content/references/PAGE_PACK.md +0 -6
  104. package/skills/easysociable-content/references/TROUBLESHOOTING.md +0 -11
  105. package/skills/easysociable-content/references/VAULT.md +0 -20
  106. package/skills/easysociable-content/scripts/setup.mjs +0 -156
  107. package/skills/easysociable-content/scripts/setup.test.mjs +0 -71
  108. package/skills/easysociable-content/scripts/starter.mjs +0 -53
  109. package/skills/easysociable-content/workflows/ANALYSIS_AND_COVERAGE.md +0 -8
  110. package/skills/easysociable-content/workflows/CONTENT_EXPORT.md +0 -19
  111. package/skills/easysociable-content/workflows/DEVICE_TASKS.md +0 -5
  112. package/skills/easysociable-content/workflows/EXPERIMENTS.md +0 -6
  113. package/skills/easysociable-content/workflows/MCP_FIRST.md +0 -10
  114. package/skills/easysociable-content/workflows/PUBLICATION.md +0 -5
  115. package/skills/easysociable-content/workflows/TOPIC_DRAFT.md +0 -5
  116. package/skills/easysociable-content/workflows/WORKING_DRAFT.md +0 -10
package/README.md CHANGED
@@ -1,7 +1,6 @@
1
1
  # EasySociable CLI
2
2
 
3
- Agent-first CLI and MCP host for EasySociable Template and Slideshow V1.
4
- Gateway-only: no Local API, SQLite, or local renderer.
3
+ Agent-first CLI for EasySociable: local Content Engine, Template, Slideshow, and MCP.
5
4
 
6
5
  ```bash
7
6
  npm i -g @easysociable/cli
@@ -9,25 +8,41 @@ npm i -g @easysociable/cli
9
8
  curl -fsSL https://easysociable.com/install.sh | bash
10
9
  ```
11
10
 
12
- Requires Node.js 20+.
11
+ Requires Node.js 20+. Content Engine workbench also needs `python3`.
13
12
 
14
13
  ## Quick start
15
14
 
16
15
  ```bash
16
+ # Visual skills + MCP for Codex / Claude Code
17
17
  easysociable install --yes
18
- # Tell your Agent what to make.
19
- # Sign up and run auth login only when Gateway asks for it.
18
+
19
+ # Local content engine (markdown + workbench) — no Agent host required
20
+ easysociable engine init --root /workspace --yes
21
+ easysociable engine serve --engine /workspace/Content Engine --no-open
20
22
  ```
21
23
 
22
- Default `install` copies **Template + Slideshow** Skills and registers the
23
- unified MCP for Codex / Claude Code. Opt into the Obsidian Content Skill later:
24
+ Then tell the Agent to follow `Content Engine/CLAUDE.md`.
25
+
26
+ Optional pointer skill for hosts that discover work via SKILL.md:
24
27
 
25
28
  ```bash
26
- easysociable install --skills content --yes
29
+ easysociable install --skills content,template,slideshow --yes
27
30
  ```
28
31
 
29
- Lazy-auth guidance for Agents lives in the package at `skills/ONBOARDING.md`
30
- (also installed into your host skills directory by `easysociable install`).
32
+ Lazy-auth guidance lives in the package at `skills/ONBOARDING.md`.
33
+
34
+ ## Content Engine
35
+
36
+ | Command | Purpose |
37
+ |---------|---------|
38
+ | `easysociable engine init [--root] [--yes] [--serve]` | Scaffold starter (never overwrites) |
39
+ | `easysociable engine serve [--engine] [--no-open]` | Build + start workbench on `:8765` |
40
+ | `easysociable engine build [--engine]` | Rebuild `workbench/index.html` |
41
+ | `easysociable engine detect` | Find engines under search roots |
42
+ | `easysociable engine validate --root …` | Starter completeness |
43
+ | `easysociable engine validate --run …` | Run / pack checks before render |
44
+
45
+ Default `--root` / `--engine` parent is `/workspace` when that directory exists, otherwise the current working directory.
31
46
 
32
47
  ## Authentication
33
48
 
@@ -38,23 +53,19 @@ easysociable templates list --json
38
53
  easysociable mcp serve --transport stdio
39
54
  ```
40
55
 
41
- Human-readable diagnostics go to stderr. With `--json`, stdout is only the
42
- versioned JSON envelope.
43
-
44
- ## Common commands
56
+ ## Other commands
45
57
 
46
58
  | Command | Purpose |
47
59
  |---------|---------|
48
60
  | `easysociable install [--yes] [--skills list]` | Install Skills + MCP |
49
61
  | `easysociable update [--yes] [--skills list]` | Update Skills + MCP |
50
- | `easysociable auth login --api-key <key>` | Store Gateway credentials |
51
62
  | `easysociable templates …` | Page Template drafts / catalog |
52
- | `easysociable slideshows …` | Temporary Slideshow Runs |
63
+ | `easysociable brands …` | Workspace Brands |
64
+ | `easysociable slideshows …` | Slideshow Runs |
53
65
  | `easysociable renders …` | Single-page final render jobs |
54
66
  | `easysociable mcp serve --transport stdio` | Unified MCP server |
55
67
 
56
- `--skills` accepts comma-separated short names: `content`, `template`,
57
- `slideshow` (default: `template,slideshow`).
68
+ `--skills` accepts: `content`, `template`, `slideshow` (default: `template,slideshow`).
58
69
 
59
70
  ## License
60
71
 
@@ -0,0 +1,13 @@
1
+ # Draft
2
+
3
+ ## Page 1 (hook)
4
+ Stop scrolling — this changed my mornings.
5
+
6
+ ## Page 2 (value)
7
+ Wake at the same time, even on weekends.
8
+
9
+ ## Page 3 (value)
10
+ No phone for the first 20 minutes after waking.
11
+
12
+ ## Page 4 (cta)
13
+ Save this and try it tomorrow.
@@ -0,0 +1,26 @@
1
+ ---
2
+ run_id: RUN-20260823-morning-routine-tips
3
+ status: pack-ready
4
+ created: 2026-08-23
5
+ ---
6
+
7
+ # Idea
8
+
9
+ - **one_liner:** Three morning habits that calm the start of the day
10
+ - **account:** your-handle
11
+ - **profile:** default
12
+ - **pillar:** P1
13
+ - **primary_platform:** tiktok
14
+ - **platforms:** tiktok
15
+ - **topic_id:** none
16
+ - **capture_ids:** none
17
+ - **swipe_id:** none
18
+ - **atoms:** none
19
+ - **claim_id:** none
20
+ - **hypothesis:** none
21
+
22
+ ## Platform (this run)
23
+
24
+ | platform | copy | images (pack) | publish status |
25
+ |----------|------|---------------|----------------|
26
+ | tiktok | yes | yes | pending |
@@ -0,0 +1,11 @@
1
+ {
2
+ "schemaVersion": "content-pack.v1",
3
+ "platform": "tiktok",
4
+ "title": "Three morning habits",
5
+ "pages": [
6
+ { "role": "hook", "pageTemplateId": "tpl_hook_001", "slots": { "text_0": "Stop scrolling — this changed my mornings" } },
7
+ { "role": "value", "pageTemplateId": "tpl_value_001", "slots": { "text_0": "Wake at the same time", "text_1": "Even on weekends" } },
8
+ { "role": "value", "pageTemplateId": "tpl_value_001", "slots": { "text_0": "No phone for 20 minutes", "text_1": "Before email or feeds" } },
9
+ { "role": "cta", "pageTemplateId": "tpl_cta_001", "slots": { "text_0": "Save this for tomorrow" } }
10
+ ]
11
+ }
@@ -0,0 +1,183 @@
1
+ /** ponytail: mirrors @easysociable/template-public-contracts — upgrade by diffing that package */
2
+
3
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
4
+ import { join, basename } from 'node:path'
5
+
6
+ export const PAGE_ROLES = [
7
+ 'cover', 'hook', 'setup', 'context', 'value', 'point', 'checklist', 'proof',
8
+ 'evidence', 'compare', 'screenshot', 'metric', 'quote', 'steps', 'cta', 'follow', 'recap',
9
+ ]
10
+
11
+ export const PACK_PLATFORMS = ['tiktok', 'xiaohongshu', 'instagram', 'linkedin', 'threads']
12
+
13
+ export const SLIDESHOW_PLATFORMS = ['tiktok', 'instagram', 'linkedin', 'threads']
14
+
15
+ export const PLATFORM_CAPS = {
16
+ tiktok: { hardMin: 4, hardMax: 35 },
17
+ xiaohongshu: { hardMin: 2, hardMax: 18 },
18
+ instagram: { hardMin: 2, hardMax: 20 },
19
+ linkedin: { hardMin: 2, hardMax: 300 },
20
+ threads: { hardMin: 2, hardMax: 20 },
21
+ }
22
+
23
+ const FORBIDDEN_PACK_KEYS = ['deck_template_id', 'deckTemplateId']
24
+
25
+ const PACK_KEYS = new Set(['schemaVersion', 'platform', 'title', 'pages'])
26
+ const PAGE_KEYS = new Set(['pageTemplateId', 'role', 'slots'])
27
+ export const RUN_FILE_STATUSES = [
28
+ 'RUN_OPEN', 'BRIEF_WAIT', 'DRAFTED', 'EDIT_WAIT', 'SCORED',
29
+ 'READY_TO_POST', 'PUBLISHED', 'HOLD', 'KILLED', 'SUPERSEDED',
30
+ ]
31
+ export const RUN_INDEX_STATUSES = [
32
+ 'open', 'brief', 'draft', 'scored', 'ready', 'published', 'hold', 'killed', 'superseded', 'partial',
33
+ ]
34
+ const RUN_STATUSES = [
35
+ ...RUN_FILE_STATUSES,
36
+ ...RUN_INDEX_STATUSES,
37
+ 'template-ready', 'pack-ready', 'handed-off', 'exported',
38
+ ]
39
+
40
+ function isRecord(value) {
41
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
42
+ }
43
+
44
+ function pushForbidden(errors, value, path) {
45
+ const text = JSON.stringify(value).toLowerCase()
46
+ for (const key of FORBIDDEN_PACK_KEYS) {
47
+ if (text.includes(key.toLowerCase())) errors.push(`${path}: forbidden field ${key}`)
48
+ }
49
+ }
50
+
51
+ function pushUnknownKeys(errors, value, allowed, path) {
52
+ for (const key of Object.keys(value)) {
53
+ if (!allowed.has(key)) errors.push(`${path}: unknown field ${key}`)
54
+ }
55
+ }
56
+
57
+ function isPublicSlotValue(value) {
58
+ return value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
59
+ }
60
+
61
+ export function validateContentPack(pack, options = {}) {
62
+ const { platform: expectedPlatform } = options
63
+ const errors = []
64
+ const warnings = []
65
+
66
+ if (!isRecord(pack)) {
67
+ errors.push('pack: must be a JSON object')
68
+ return { valid: false, errors, warnings }
69
+ }
70
+
71
+ pushForbidden(errors, pack, 'pack')
72
+ pushUnknownKeys(errors, pack, PACK_KEYS, 'pack')
73
+
74
+ if (pack.schemaVersion !== 'content-pack.v1') errors.push('schemaVersion: must be content-pack.v1')
75
+ if (!PACK_PLATFORMS.includes(pack.platform)) {
76
+ errors.push(`platform: must be one of ${PACK_PLATFORMS.join(', ')}`)
77
+ }
78
+ if (expectedPlatform && pack.platform !== expectedPlatform) {
79
+ errors.push(`platform: expected ${expectedPlatform}, got ${pack.platform}`)
80
+ }
81
+ if (pack.title !== undefined && typeof pack.title !== 'string') errors.push('title: must be a string')
82
+ if (!Array.isArray(pack.pages) || pack.pages.length === 0) {
83
+ errors.push('pages: must be a non-empty array')
84
+ return { valid: errors.length === 0, errors, warnings }
85
+ }
86
+
87
+ const cap = PLATFORM_CAPS[pack.platform]
88
+ if (cap && (pack.pages.length < cap.hardMin || pack.pages.length > cap.hardMax)) {
89
+ errors.push(`pages: ${pack.platform} requires ${cap.hardMin}-${cap.hardMax} pages, got ${pack.pages.length}`)
90
+ }
91
+
92
+ pack.pages.forEach((page, index) => {
93
+ const label = `pages[${index}]`
94
+ if (!isRecord(page)) {
95
+ errors.push(`${label}: must be an object`)
96
+ return
97
+ }
98
+ pushUnknownKeys(errors, page, PAGE_KEYS, label)
99
+ if (!PAGE_ROLES.includes(page.role)) errors.push(`${label}.role: invalid page role`)
100
+ if (typeof page.pageTemplateId !== 'string' || page.pageTemplateId.length === 0) {
101
+ errors.push(`${label}.pageTemplateId: required and must be a non-empty string`)
102
+ }
103
+ if (!isRecord(page.slots)) {
104
+ errors.push(`${label}.slots: must be an object`)
105
+ }
106
+ else {
107
+ for (const [key, value] of Object.entries(page.slots)) {
108
+ if (!isPublicSlotValue(value)) errors.push(`${label}.slots.${key}: must be string, number, boolean or null`)
109
+ }
110
+ }
111
+ })
112
+
113
+ if (!SLIDESHOW_PLATFORMS.includes(pack.platform)) {
114
+ warnings.push(`platform ${pack.platform}: not supported by easysociable-slideshow in V1`)
115
+ }
116
+
117
+ return { valid: errors.length === 0, errors, warnings }
118
+ }
119
+
120
+ export function parseFrontmatter(text) {
121
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)
122
+ if (!match) return {}
123
+ const fields = {}
124
+ for (const line of match[1].split('\n')) {
125
+ const idx = line.indexOf(':')
126
+ if (idx === -1) continue
127
+ const key = line.slice(0, idx).trim()
128
+ const value = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, '')
129
+ if (key) fields[key] = value
130
+ }
131
+ return fields
132
+ }
133
+
134
+ export function ideaField(text, name) {
135
+ const match = text.match(new RegExp(`\\*\\*${name}:\\*\\*\\s*(.+)`, 'i'))
136
+ return match ? match[1].trim() : ''
137
+ }
138
+
139
+ export function validateRunLayout(runDir, options = {}) {
140
+ const { requirePack = false } = options
141
+ const errors = []
142
+ const warnings = []
143
+ const files = { ideaMd: false, draftMd: false, packMd: false, packs: [] }
144
+ const ideaPath = join(runDir, 'idea.md')
145
+
146
+ if (!existsSync(runDir) || !statSync(runDir).isDirectory()) {
147
+ errors.push('run: directory required')
148
+ return { valid: false, errors, warnings, files, frontmatter: {} }
149
+ }
150
+ if (!existsSync(ideaPath)) {
151
+ errors.push('idea.md: required')
152
+ return { valid: false, errors, warnings, files, frontmatter: {} }
153
+ }
154
+
155
+ files.ideaMd = true
156
+ const ideaText = readFileSync(ideaPath, 'utf8')
157
+ const frontmatter = parseFrontmatter(ideaText)
158
+ const platform = (frontmatter.platform || ideaField(ideaText, 'primary_platform') || ideaField(ideaText, 'platforms')).replace(/\s.*/, '')
159
+ frontmatter.platform = platform
160
+ frontmatter.slug = frontmatter.slug || basename(runDir)
161
+ if (!frontmatter.run_id) errors.push('idea.md frontmatter: missing run_id')
162
+ if (!frontmatter.status) errors.push('idea.md frontmatter: missing status')
163
+ if (frontmatter.status && !RUN_STATUSES.includes(frontmatter.status)) {
164
+ errors.push(`idea.md status: must be one of the engine run statuses`)
165
+ }
166
+ if (!platform) warnings.push('idea.md: primary_platform recommended')
167
+
168
+ files.draftMd = existsSync(join(runDir, 'draft.md'))
169
+ files.packMd = existsSync(join(runDir, 'pack.md')) || readdirSync(runDir).some((name) => /^pack-[a-z0-9_-]+\.md$/i.test(name))
170
+ if (!files.draftMd) warnings.push('draft.md: recommended')
171
+
172
+ files.packs = readdirSync(runDir).filter((name) => /^pack-[a-z0-9_-]+\.json$/i.test(name))
173
+ const slideshowRun = SLIDESHOW_PLATFORMS.includes(platform) || platform === 'ig'
174
+ const expectedPack = platform === 'ig' ? 'pack-instagram.json' : (platform ? `pack-${platform}.json` : 'pack-<platform>.json')
175
+ if (requirePack && files.packs.length === 0) {
176
+ errors.push(`${expectedPack}: required for slideshow handoff`)
177
+ }
178
+ if (!slideshowRun && files.packs.length > 0) {
179
+ errors.push('pack-<platform>.json: only slideshow platforms may contain a Content Pack')
180
+ }
181
+
182
+ return { valid: errors.length === 0, errors, warnings, files, frontmatter }
183
+ }
@@ -0,0 +1,76 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+ import { createHash } from 'node:crypto'
4
+
5
+ function isRecord(value) {
6
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
7
+ }
8
+
9
+ function sha256(bytes) {
10
+ return createHash('sha256').update(bytes).digest('hex')
11
+ }
12
+
13
+ export function validateExportDirectory(exportDir, options = {}) {
14
+ const expectedPages = options.expectedPages
15
+ const errors = []
16
+ const warnings = []
17
+ const files = { manifest: false, pngs: [] }
18
+
19
+ if (!existsSync(exportDir) || !statSync(exportDir).isDirectory()) {
20
+ return { valid: false, errors: ['export: directory required'], warnings, files }
21
+ }
22
+
23
+ const manifestPath = join(exportDir, 'manifest.json')
24
+ if (!existsSync(manifestPath)) {
25
+ errors.push('manifest.json: required')
26
+ return { valid: false, errors, warnings, files }
27
+ }
28
+ files.manifest = true
29
+
30
+ let manifest
31
+ try {
32
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
33
+ }
34
+ catch (error) {
35
+ errors.push(`manifest.json: invalid JSON (${error instanceof Error ? error.message : String(error)})`)
36
+ return { valid: false, errors, warnings, files }
37
+ }
38
+ if (!isRecord(manifest)) errors.push('manifest.json: must contain an object')
39
+
40
+ const entries = Array.isArray(manifest?.pages)
41
+ ? manifest.pages
42
+ : Array.isArray(manifest?.artifacts) ? manifest.artifacts : []
43
+ if (entries.length === 0) errors.push('manifest.json: pages or artifacts must be a non-empty array')
44
+
45
+ files.pngs = readdirSync(exportDir).filter(name => /^\d{2}(?:-[^/]+)?\.png$/i.test(name) && statSync(join(exportDir, name)).isFile())
46
+ if (entries.length > 0 && files.pngs.length !== entries.length) {
47
+ errors.push(`export: manifest has ${entries.length} page(s), found ${files.pngs.length} PNG file(s)`)
48
+ }
49
+ if (Number.isInteger(expectedPages) && expectedPages > 0 && entries.length !== expectedPages) {
50
+ errors.push(`export: expected ${expectedPages} page(s), manifest has ${entries.length}`)
51
+ }
52
+
53
+ const pngByName = new Map(files.pngs.map(name => [name, name]))
54
+ entries.forEach((entry, index) => {
55
+ if (!isRecord(entry)) {
56
+ errors.push(`manifest[${index}]: must be an object`)
57
+ return
58
+ }
59
+ const file = typeof entry.file === 'string' && entry.file.length > 0
60
+ ? basename(entry.file)
61
+ : `${String(index + 1).padStart(2, '0')}.png`
62
+ if (file !== entry.file && entry.file !== undefined) errors.push(`manifest[${index}].file: must be a local basename`)
63
+ if (!pngByName.has(file)) {
64
+ errors.push(`manifest[${index}].file: missing ${file}`)
65
+ return
66
+ }
67
+ const bytes = readFileSync(join(exportDir, file))
68
+ if (bytes.length === 0) errors.push(`${file}: empty file`)
69
+ if (bytes.length < 8 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') {
70
+ errors.push(`${file}: not a PNG file`)
71
+ }
72
+ if (entry.sha256 && entry.sha256 !== sha256(bytes)) errors.push(`${file}: sha256 mismatch`)
73
+ })
74
+
75
+ return { valid: errors.length === 0, errors, warnings, files, manifest }
76
+ }
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { spawnSync } from 'node:child_process'
5
+
6
+ const root = new URL('..', import.meta.url).pathname
7
+ const goldenRun = join(root, 'fixtures/golden-run-en')
8
+ const validateRun = join(root, 'scripts/validate-run.mjs')
9
+ const validatePack = join(root, 'scripts/validate-pack.mjs')
10
+
11
+ const packFile = join(goldenRun, 'pack-tiktok.json')
12
+ const packCli = spawnSync(process.execPath, [validatePack, '--file', packFile, '--platform', 'tiktok', '--json'], { encoding: 'utf8' })
13
+ assert.equal(packCli.status, 0, packCli.stdout + packCli.stderr)
14
+
15
+ const runCli = spawnSync(process.execPath, [validateRun, '--run', goldenRun, '--require-pack', '--json'], { encoding: 'utf8' })
16
+ assert.equal(runCli.status, 0, runCli.stdout + runCli.stderr)
17
+ const runReport = JSON.parse(runCli.stdout)
18
+ assert.equal(runReport.valid, true)
19
+ assert.equal(runReport.layout.frontmatter.status, 'pack-ready')
20
+
21
+ const pack = JSON.parse(readFileSync(packFile, 'utf8'))
22
+ assert.equal(pack.pages.length, 4)
23
+
24
+ console.log('Golden fixture validation passed.')
@@ -0,0 +1,55 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ export const ENGINE_ROOT = 'Content Engine'
6
+ export const STARTER_DIR = fileURLToPath(new URL('../starter/', import.meta.url))
7
+ export const WORKBENCH_SRC = fileURLToPath(new URL('../workbench/', import.meta.url))
8
+ export const WORKBENCH_FILES = ['template.html', 'build.py', 'serve.py']
9
+
10
+ export const ENGINE_PATHS = {
11
+ runs: 'runs',
12
+ exports: 'exports',
13
+ topics: 'topics',
14
+ discovery: 'discovery',
15
+ swipe: 'swipe',
16
+ profiles: 'profiles',
17
+ inbox: 'inbox',
18
+ accounts: 'accounts',
19
+ raw: 'raw',
20
+ published: 'published',
21
+ wiki: 'wiki',
22
+ platforms: 'platforms',
23
+ workbench: 'workbench',
24
+ }
25
+
26
+ export function engineJson() {
27
+ return `${JSON.stringify({
28
+ schemaVersion: 'content-engine.v1',
29
+ locale: 'en',
30
+ root: ENGINE_ROOT,
31
+ paths: ENGINE_PATHS,
32
+ }, null, 2)}\n`
33
+ }
34
+
35
+ function walkFiles(dir) {
36
+ const out = []
37
+ if (!existsSync(dir)) return out
38
+ for (const name of readdirSync(dir)) {
39
+ if (name === '.DS_Store') continue
40
+ const path = join(dir, name)
41
+ if (statSync(path).isDirectory()) out.push(...walkFiles(path))
42
+ else out.push(path)
43
+ }
44
+ return out
45
+ }
46
+
47
+ export function filesFor() {
48
+ const files = {}
49
+ for (const path of walkFiles(STARTER_DIR)) {
50
+ const relativePath = relative(STARTER_DIR, path).split('\\').join('/')
51
+ files[relativePath] = readFileSync(path, 'utf8')
52
+ }
53
+ files['engine.json'] = engineJson()
54
+ return files
55
+ }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolve } from 'node:path'
4
+ import { validateExportDirectory } from './export-contracts.mjs'
5
+
6
+ function parseArgs(argv) {
7
+ const options = { exportDir: '', expectedPages: undefined, json: false }
8
+ for (let i = 0; i < argv.length; i += 1) {
9
+ const arg = argv[i]
10
+ if (arg === '--export') options.exportDir = argv[++i] ?? ''
11
+ else if (arg === '--expected-pages') options.expectedPages = Number(argv[++i])
12
+ else if (arg === '--json') options.json = true
13
+ else if (arg === '--help' || arg === '-h') options.help = true
14
+ else throw new Error(`Unknown argument: ${arg}`)
15
+ }
16
+ return options
17
+ }
18
+
19
+ function output(value, json) {
20
+ process.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : `${value.message ?? value}\n`)
21
+ }
22
+
23
+ try {
24
+ const options = parseArgs(process.argv.slice(2))
25
+ if (options.help) {
26
+ output({ usage: 'node validate-export.mjs --export <export-dir> [--expected-pages N] [--json]' }, false)
27
+ process.exit(0)
28
+ }
29
+ if (!options.exportDir) {
30
+ output({ code: 'EXPORT_REQUIRED', message: 'Pass --export <export-dir>.', valid: false }, options.json)
31
+ process.exitCode = 2
32
+ }
33
+ else {
34
+ const report = validateExportDirectory(resolve(options.exportDir), { expectedPages: options.expectedPages })
35
+ output({ ...report, action: 'validated', export: resolve(options.exportDir) }, options.json)
36
+ if (!report.valid) process.exitCode = 2
37
+ }
38
+ }
39
+ catch (error) {
40
+ output({ code: 'EXPORT_VALIDATE_FAILED', message: error instanceof Error ? error.message : String(error), valid: false }, true)
41
+ process.exitCode = 1
42
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'node:fs'
4
+ import { resolve } from 'node:path'
5
+ import { validateContentPack } from './contracts.mjs'
6
+
7
+ function parseArgs(argv) {
8
+ const options = { file: '', platform: '', requirePageTemplateId: false, json: false }
9
+ for (let i = 0; i < argv.length; i += 1) {
10
+ const arg = argv[i]
11
+ if (arg === '--file') options.file = argv[++i] ?? ''
12
+ else if (arg === '--platform') options.platform = argv[++i] ?? ''
13
+ else if (arg === '--require-page-template-id') options.requirePageTemplateId = true
14
+ else if (arg === '--json') options.json = true
15
+ else if (arg === '--help' || arg === '-h') options.help = true
16
+ else throw new Error(`Unknown argument: ${arg}`)
17
+ }
18
+ return options
19
+ }
20
+
21
+ function output(value, json) {
22
+ process.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : `${value.message ?? value}\n`)
23
+ }
24
+
25
+ try {
26
+ const options = parseArgs(process.argv.slice(2))
27
+ if (options.help) {
28
+ output({ usage: 'node validate-pack.mjs --file <pack.json> [--platform tiktok] [--require-page-template-id] [--json]' }, false)
29
+ process.exit(0)
30
+ }
31
+ if (!options.file) {
32
+ output({ code: 'FILE_REQUIRED', message: 'Pass --file <pack.json>.', valid: false }, options.json)
33
+ process.exitCode = 2
34
+ }
35
+ else {
36
+ const pack = JSON.parse(readFileSync(resolve(options.file), 'utf8'))
37
+ const report = validateContentPack(pack, {
38
+ requirePageTemplateId: options.requirePageTemplateId,
39
+ platform: options.platform || undefined,
40
+ })
41
+ output({ ...report, action: 'validated', file: resolve(options.file) }, options.json)
42
+ if (!report.valid) process.exitCode = 2
43
+ }
44
+ }
45
+ catch (error) {
46
+ output({ code: 'PACK_VALIDATE_FAILED', message: error instanceof Error ? error.message : String(error), valid: false }, true)
47
+ process.exitCode = 1
48
+ }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import { join, resolve } from 'node:path'
5
+ import { validateContentPack, validateRunLayout } from './contracts.mjs'
6
+ import { validateExportDirectory } from './export-contracts.mjs'
7
+
8
+ function parseArgs(argv) {
9
+ const options = { run: '', exportDir: '', requirePack: false, requireExport: false, requirePageTemplateId: false, json: false }
10
+ for (let i = 0; i < argv.length; i += 1) {
11
+ const arg = argv[i]
12
+ if (arg === '--run') options.run = argv[++i] ?? ''
13
+ else if (arg === '--export') options.exportDir = argv[++i] ?? ''
14
+ else if (arg === '--require-pack') options.requirePack = true
15
+ else if (arg === '--require-export') options.requireExport = true
16
+ else if (arg === '--require-page-template-id') options.requirePageTemplateId = true
17
+ else if (arg === '--json') options.json = true
18
+ else if (arg === '--help' || arg === '-h') options.help = true
19
+ else throw new Error(`Unknown argument: ${arg}`)
20
+ }
21
+ return options
22
+ }
23
+
24
+ function output(value, json) {
25
+ process.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : `${value.message ?? value}\n`)
26
+ }
27
+
28
+ try {
29
+ const options = parseArgs(process.argv.slice(2))
30
+ if (options.help) {
31
+ output({ usage: 'node validate-run.mjs --run <run-dir> [--require-pack] [--require-export] [--export <export-dir>] [--json]' }, false)
32
+ process.exit(0)
33
+ }
34
+ if (!options.run) {
35
+ output({ code: 'RUN_REQUIRED', message: 'Pass --run <run-dir>.', valid: false }, options.json)
36
+ process.exitCode = 2
37
+ }
38
+ else {
39
+ const runDir = resolve(options.run)
40
+ const layout = validateRunLayout(runDir, { requirePack: options.requirePack })
41
+ const packs = []
42
+ for (const name of layout.files.packs) {
43
+ const platform = name.replace(/^pack-/, '').replace(/\.json$/i, '')
44
+ try {
45
+ const pack = JSON.parse(readFileSync(join(runDir, name), 'utf8'))
46
+ packs.push({ file: name, pages: Array.isArray(pack.pages) ? pack.pages.length : 0, ...validateContentPack(pack, {
47
+ requirePageTemplateId: options.requirePageTemplateId,
48
+ platform,
49
+ }) })
50
+ }
51
+ catch (error) {
52
+ packs.push({ file: name, pages: 0, valid: false, errors: [`invalid JSON (${error instanceof Error ? error.message : String(error)})`], warnings: [] })
53
+ }
54
+ }
55
+ const packErrors = packs.flatMap((item) => item.errors.map((e) => `${item.file}: ${e}`))
56
+ let exportReport
57
+ const needsExport = options.requireExport || layout.frontmatter.status === 'exported'
58
+ if (needsExport) {
59
+ let exportDir = options.exportDir
60
+ if (!exportDir) {
61
+ const engineRoot = resolve(runDir, '..', '..')
62
+ const engineConfig = join(engineRoot, 'engine.json')
63
+ if (existsSync(engineConfig)) {
64
+ try {
65
+ const engine = JSON.parse(readFileSync(engineConfig, 'utf8'))
66
+ const base = join(engineRoot, engine.paths.exports, layout.frontmatter.slug)
67
+ const nested = layout.frontmatter.platform ? join(base, layout.frontmatter.platform) : ''
68
+ exportDir = nested && existsSync(nested) ? nested : base
69
+ }
70
+ catch (error) {
71
+ exportReport = { valid: false, errors: [`engine.json: invalid JSON (${error instanceof Error ? error.message : String(error)})`], warnings: [] }
72
+ }
73
+ }
74
+ else exportReport = { valid: false, errors: ['engine.json: required to derive export path'], warnings: [] }
75
+ }
76
+ if (!exportReport) {
77
+ const expectedPages = packs.find(item => item.pages > 0)?.pages ?? 1
78
+ exportReport = validateExportDirectory(resolve(exportDir), { expectedPages })
79
+ exportReport.export = resolve(exportDir)
80
+ }
81
+ }
82
+ const exportErrors = exportReport?.errors?.map(error => `export: ${error}`) ?? []
83
+ const valid = layout.valid && packErrors.length === 0 && exportErrors.length === 0
84
+ output({
85
+ valid,
86
+ action: 'validated',
87
+ run: runDir,
88
+ layout,
89
+ packs,
90
+ export: exportReport,
91
+ errors: [...layout.errors, ...packErrors, ...exportErrors],
92
+ warnings: [...layout.warnings, ...packs.flatMap((item) => item.warnings.map((w) => `${item.file}: ${w}`)), ...(exportReport?.warnings ?? [])],
93
+ }, options.json)
94
+ if (!valid) process.exitCode = 2
95
+ }
96
+ }
97
+ catch (error) {
98
+ output({ code: 'RUN_VALIDATE_FAILED', message: error instanceof Error ? error.message : String(error), valid: false }, true)
99
+ process.exitCode = 1
100
+ }