primo-cli 0.1.2 → 0.1.4

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 (39) hide show
  1. package/README.md +113 -41
  2. package/dist/commands/build.js +488 -272
  3. package/dist/commands/deploy.d.ts +1 -1
  4. package/dist/commands/deploy.js +293 -141
  5. package/dist/commands/dev.d.ts +2 -0
  6. package/dist/commands/dev.js +2007 -150
  7. package/dist/commands/init.d.ts +2 -2
  8. package/dist/commands/init.js +65 -43
  9. package/dist/commands/login.d.ts +1 -2
  10. package/dist/commands/login.js +24 -6
  11. package/dist/commands/new.js +161 -274
  12. package/dist/commands/pull-library.d.ts +7 -0
  13. package/dist/commands/pull-library.js +92 -0
  14. package/dist/commands/pull.d.ts +0 -1
  15. package/dist/commands/pull.js +160 -165
  16. package/dist/commands/push-library.d.ts +7 -0
  17. package/dist/commands/push-library.js +88 -0
  18. package/dist/commands/push.d.ts +2 -0
  19. package/dist/commands/push.js +358 -51
  20. package/dist/commands/validate.d.ts +1 -1
  21. package/dist/commands/validate.js +379 -161
  22. package/dist/index.js +110 -20
  23. package/dist/utils/binary.js +1 -1
  24. package/dist/utils/format.d.ts +12 -0
  25. package/dist/utils/format.js +98 -0
  26. package/dist/utils/head-svelte.d.ts +2 -0
  27. package/dist/utils/head-svelte.js +53 -0
  28. package/dist/utils/server-config.d.ts +19 -0
  29. package/dist/utils/server-config.js +49 -0
  30. package/dist/utils/site-config.d.ts +11 -0
  31. package/dist/utils/site-config.js +14 -0
  32. package/package.json +9 -5
  33. package/scripts/postinstall.js +1 -1
  34. package/dist/commands/export.d.ts +0 -8
  35. package/dist/commands/export.js +0 -163
  36. package/dist/commands/import.d.ts +0 -9
  37. package/dist/commands/import.js +0 -118
  38. package/dist/commands/publish.d.ts +0 -6
  39. package/dist/commands/publish.js +0 -239
@@ -4,15 +4,43 @@ import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import inquirer from 'inquirer';
6
6
  import { dev_server } from './dev.js';
7
+ import { SITE_CONFIG_FILE, write_site_config } from '../utils/site-config.js';
8
+ import { SERVER_CONFIG_FILE, read_server_config, write_server_config } from '../utils/server-config.js';
7
9
  export async function new_site(options) {
8
10
  const base_dir = process.cwd();
9
- const server_config_path = path.join(base_dir, 'server.json');
10
- // Auto-create server.json if it doesn't exist
11
+ const server_config_path = path.join(base_dir, SERVER_CONFIG_FILE);
12
+ const site_config_path = path.join(base_dir, SITE_CONFIG_FILE);
13
+ const sites_dir = path.join(base_dir, 'sites');
14
+ const library_dir = path.join(base_dir, 'library');
15
+ try {
16
+ await fs.access(site_config_path);
17
+ console.log(chalk.red(`Found ${SITE_CONFIG_FILE} in the current directory.`));
18
+ console.log(chalk.red('Run `primo new` from a workspace root, not inside a site directory.'));
19
+ process.exit(1);
20
+ }
21
+ catch {
22
+ // Not inside a site directory, continue.
23
+ }
24
+ // Require an initialized workspace
11
25
  try {
12
26
  await fs.access(server_config_path);
13
27
  }
14
28
  catch {
15
- await fs.writeFile(server_config_path, JSON.stringify({ port: 3000 }, null, 2) + '\n');
29
+ console.log(chalk.red(`No ${SERVER_CONFIG_FILE} found in the current directory.`));
30
+ console.log(chalk.dim('Run `primo init [name]` first to create a workspace.'));
31
+ process.exit(1);
32
+ }
33
+ await fs.mkdir(sites_dir, { recursive: true });
34
+ await fs.mkdir(library_dir, { recursive: true });
35
+ const server_config = await read_server_config(base_dir);
36
+ const site_groups = server_config.site_groups ?? [];
37
+ if (!site_groups.some((group) => group.id === 'default')) {
38
+ site_groups.push({
39
+ id: 'default',
40
+ name: 'Default',
41
+ index: site_groups.length
42
+ });
43
+ await write_server_config(base_dir, { ...server_config, site_groups });
16
44
  }
17
45
  let site_name = options.name;
18
46
  // Prompt for name if not provided
@@ -32,8 +60,8 @@ export async function new_site(options) {
32
60
  }]);
33
61
  site_name = name;
34
62
  }
35
- // Always create site in subdirectory
36
- const site_dir = path.join(base_dir, site_name);
63
+ // Always create site in sites/<name>
64
+ const site_dir = path.join(sites_dir, site_name);
37
65
  // Check if directory exists
38
66
  try {
39
67
  await fs.access(site_dir);
@@ -51,8 +79,7 @@ export async function new_site(options) {
51
79
  await fs.mkdir(path.join(site_dir, 'pages'), { recursive: true });
52
80
  await fs.mkdir(path.join(site_dir, 'page-types', 'default'), { recursive: true });
53
81
  await fs.mkdir(path.join(site_dir, 'site'), { recursive: true });
54
- await fs.mkdir(path.join(site_dir, 'uploads'), { recursive: true });
55
- // Create primo.json
82
+ // Create site config
56
83
  // If name has dots (hostname), use first part capitalized as display name
57
84
  const display_name = site_name.includes('.')
58
85
  ? site_name.split('.')[0].charAt(0).toUpperCase() + site_name.split('.')[0].slice(1)
@@ -62,20 +89,36 @@ export async function new_site(options) {
62
89
  site_id: generate_id(),
63
90
  // Leave host empty for local dev - dev.ts will generate coffee-shop.localhost:3000
64
91
  // Only set host if it looks like a real domain (has a dot)
65
- host: site_name.includes('.') ? site_name : ''
92
+ host: site_name.includes('.') ? site_name : '',
93
+ group: 'default'
66
94
  };
67
- await fs.writeFile(path.join(site_dir, 'primo.json'), JSON.stringify(config, null, 2) + '\n');
68
- // Create default page type config
95
+ await write_site_config(site_dir, config);
96
+ // Create default page type config + sibling fields.yaml
69
97
  const page_type_config = {
70
- id: generate_id(),
98
+ _id: generate_id(),
71
99
  name: 'Default',
72
100
  icon: 'mdi:file-document-outline',
73
- allowed_blocks: ['hero'],
74
- fields: []
101
+ color: '#2B407D',
102
+ allowed_blocks: ['hero']
75
103
  };
76
- await fs.writeFile(path.join(site_dir, 'page-types', 'default', 'config.json'), JSON.stringify(page_type_config, null, 2) + '\n');
104
+ await fs.writeFile(path.join(site_dir, 'page-types', 'default', 'config.yaml'), `_id: ${page_type_config._id}
105
+ name: ${page_type_config.name}
106
+ icon: ${page_type_config.icon}
107
+ color: "${page_type_config.color}"
108
+ allowed_blocks:
109
+ - hero
110
+ `);
111
+ await fs.writeFile(path.join(site_dir, 'page-types', 'default', 'fields.yaml'), '[]\n');
112
+ await fs.writeFile(path.join(site_dir, 'page-types', 'default', 'layout.yaml'), `# Sections shared by every page of this type. Add blocks here to render
113
+ # the same header/footer across all pages of this type.
114
+ #
115
+ # header:
116
+ # - block: site-header
117
+ # footer:
118
+ # - block: site-footer
119
+ `);
77
120
  // Create site fields (empty array)
78
- await fs.writeFile(path.join(site_dir, 'site', 'fields.json'), '[]\n');
121
+ await fs.writeFile(path.join(site_dir, 'site', 'fields.yaml'), '[]\n');
79
122
  // Create site content (empty)
80
123
  await fs.writeFile(path.join(site_dir, 'site', 'content.yaml'), '# Site-wide content\n');
81
124
  // Create site head with CSS variables
@@ -94,18 +137,24 @@ export async function new_site(options) {
94
137
  }
95
138
  </style>
96
139
  `);
97
- // Create starter hero block
140
+ // Create starter hero block (4 files: config + fields + content + component)
98
141
  await fs.mkdir(path.join(site_dir, 'blocks', 'hero'), { recursive: true });
99
- const hero_fields = {
100
- id: generate_id(),
101
- name: 'Hero',
102
- fields: [
103
- { id: generate_id(), name: 'headline', label: 'Headline', type: 'text', options: null },
104
- { id: generate_id(), name: 'subheadline', label: 'Subheadline', type: 'text', options: null },
105
- { id: generate_id(), name: 'cta', label: 'Call to Action', type: 'link', options: null }
106
- ]
107
- };
108
- await fs.writeFile(path.join(site_dir, 'blocks', 'hero', 'fields.json'), JSON.stringify(hero_fields, null, 2) + '\n');
142
+ await fs.writeFile(path.join(site_dir, 'blocks', 'hero', 'config.yaml'), `_id: ${generate_id()}
143
+ name: Hero
144
+ `);
145
+ await fs.writeFile(path.join(site_dir, 'blocks', 'hero', 'fields.yaml'), `- _id: ${generate_id()}
146
+ name: headline
147
+ label: Headline
148
+ type: text
149
+ - _id: ${generate_id()}
150
+ name: subheadline
151
+ label: Subheadline
152
+ type: text
153
+ - _id: ${generate_id()}
154
+ name: cta
155
+ label: Call to Action
156
+ type: link
157
+ `);
109
158
  await fs.writeFile(path.join(site_dir, 'blocks', 'hero', 'component.svelte'), `<section class="hero">
110
159
  <div class="container">
111
160
  <h1>{headline}</h1>
@@ -174,13 +223,14 @@ cta:
174
223
  `);
175
224
  // Create index page
176
225
  const page_id = generate_id();
177
- await fs.writeFile(path.join(site_dir, 'pages', 'index.yaml'), `id: ${page_id}
226
+ const section_id = generate_id();
227
+ await fs.writeFile(path.join(site_dir, 'pages', 'index.yaml'), `_id: ${page_id}
178
228
  name: Home
179
- slug: index
180
229
  page_type: default
181
230
  fields: {}
182
231
  sections:
183
- - block: hero
232
+ - _id: ${section_id}
233
+ block: hero
184
234
  content:
185
235
  headline: Welcome to ${display_name}
186
236
  subheadline: Edit this content in your local files or CMS
@@ -188,8 +238,16 @@ sections:
188
238
  label: Get Started
189
239
  url: "#"
190
240
  `);
191
- // Create AGENT.md
192
- await fs.writeFile(path.join(site_dir, 'AGENT.md'), generate_agent_md(display_name));
241
+ // Create workspace-level AGENTS.md if it doesn't already exist.
242
+ // Per-site AGENTS.md is generated by `export.go` for portable exports;
243
+ // in a workspace, one root-level file covers all sites.
244
+ const workspace_agents_path = path.join(base_dir, 'AGENTS.md');
245
+ try {
246
+ await fs.access(workspace_agents_path);
247
+ }
248
+ catch {
249
+ await fs.writeFile(workspace_agents_path, generate_agent_md());
250
+ }
193
251
  spinner.succeed(`Site created: ${chalk.cyan(site_dir)}`);
194
252
  // Check if server is already running
195
253
  const port = 3000;
@@ -245,263 +303,92 @@ async function is_server_running(port) {
245
303
  return false;
246
304
  }
247
305
  }
248
- function generate_agent_md(site_name) {
249
- return `# ${site_name}
250
-
251
- Pala site for local development.
252
-
253
- ## Structure
254
-
255
- \`\`\`
256
- blocks/ # Svelte components with content fields
257
- {name}/
258
- component.svelte
259
- fields.json
260
- content.yaml # Default field values (optional)
261
- page-types/ # Page templates
262
- {name}/
263
- config.json
264
- pages/ # Page content (YAML)
265
- index.yaml # Homepage
266
- contact.yaml # Leaf page (/contact)
267
- about/ # Section with children
268
- index.yaml # /about
269
- team.yaml # /about/team
270
- site/ # Site-wide settings
271
- fields.json
272
- content.yaml
273
- head.svelte # Injected into <head>
274
- .primo/ # Internal metadata
275
- \`\`\`
276
-
277
- ## Creating Blocks
278
-
279
- Each block needs two files:
280
-
281
- **component.svelte** - Svelte 5 component:
282
- \`\`\`svelte
283
- <h1>{headline}</h1>
284
- {#if image?.url}
285
- <img src={image.url} alt={image.alt} />
286
- {/if}
306
+ function generate_agent_md() {
307
+ return `# Primo workspace
287
308
 
288
- <style>
289
- h1 { font-size: 2rem; }
290
- </style>
291
- \`\`\`
292
-
293
- **Note:** Props are auto-injected from fields.json. No need to declare \`$props()\` - just use the field names directly in your template.
294
-
295
- **fields.json** - Field definitions:
296
- \`\`\`json
297
- {
298
- "name": "Hero",
299
- "fields": [
300
- { "name": "headline", "label": "Headline", "type": "text" },
301
- { "name": "image", "label": "Image", "type": "image" }
302
- ]
303
- }
304
- \`\`\`
305
-
306
- ## Field Types
307
-
308
- ### text
309
- Single-line text input.
310
- \`\`\`svelte
311
- <h1>{headline}</h1>
312
- \`\`\`
313
-
314
- ### rich-text
315
- WYSIWYG editor. Outputs HTML.
316
- \`\`\`svelte
317
- {@html content}
318
- \`\`\`
319
-
320
- ### markdown
321
- Markdown editor. Outputs HTML.
322
- \`\`\`svelte
323
- {@html body}
324
- \`\`\`
325
-
326
- ### image
327
- Image upload. Returns \`{ url, alt, width, height }\`.
328
- \`\`\`svelte
329
- {#if image?.url}
330
- <img src={image.url} alt={image.alt} />
331
- {/if}
332
- \`\`\`
333
-
334
- ### link
335
- URL with label. Returns \`{ url, label }\`.
336
- \`\`\`svelte
337
- {#if cta?.url}
338
- <a href={cta.url}>{cta.label}</a>
339
- {/if}
340
- \`\`\`
341
-
342
- ### url
343
- Plain URL string.
344
- \`\`\`svelte
345
- <a href={website_url}>Visit</a>
346
- \`\`\`
347
-
348
- ### icon
349
- Icon picker. Returns SVG string.
350
- \`\`\`svelte
351
- {@html icon}
352
- \`\`\`
353
-
354
- ### number
355
- Numeric input.
356
- \`\`\`json
357
- { "name": "columns", "type": "number", "options": { "min": 1, "max": 6 } }
358
- \`\`\`
359
-
360
- ### switch
361
- Boolean toggle.
362
- \`\`\`svelte
363
- {#if show_title}<h1>{title}</h1>{/if}
364
- \`\`\`
365
-
366
- ### select
367
- Dropdown selection.
368
- \`\`\`json
369
- { "name": "align", "type": "select", "options": { "choices": ["left", "center", "right"] } }
370
- \`\`\`
371
- \`\`\`svelte
372
- <div class="text-{align}">{content}</div>
373
- \`\`\`
374
-
375
- ### repeater
376
- List of items with nested fields.
377
- \`\`\`json
378
- {
379
- "name": "features",
380
- "type": "repeater",
381
- "options": {
382
- "fields": [
383
- { "name": "title", "type": "text" },
384
- { "name": "description", "type": "text" }
385
- ]
386
- }
387
- }
388
- \`\`\`
389
- \`\`\`svelte
390
- {#each features as feature}
391
- <div>
392
- <h3>{feature.title}</h3>
393
- <p>{feature.description}</p>
394
- </div>
395
- {/each}
396
- \`\`\`
397
-
398
- ### group
399
- Nested object of fields.
400
- \`\`\`json
401
- {
402
- "name": "author",
403
- "type": "group",
404
- "options": {
405
- "fields": [
406
- { "name": "name", "type": "text" },
407
- { "name": "avatar", "type": "image" }
408
- ]
409
- }
410
- }
411
- \`\`\`
412
- \`\`\`svelte
413
- <div>{author.name}</div>
414
- {#if author.avatar?.url}<img src={author.avatar.url} />{/if}
415
- \`\`\`
416
-
417
- ### page
418
- Reference to another page. Returns page data with \`_meta.url\`.
419
- \`\`\`json
420
- { "name": "featured_post", "type": "page", "options": { "page_type": "blog-post" } }
421
- \`\`\`
422
-
423
- ### page-list
424
- All pages of a type.
425
- \`\`\`json
426
- { "name": "posts", "type": "page-list", "options": { "page_type": "blog-post" } }
427
- \`\`\`
428
-
429
- ### page-field
430
- Reference a field from the current page type.
431
-
432
- ### site-field
433
- Reference a site-wide field.
434
-
435
- ### slider
436
- Range slider for numeric values.
437
- \`\`\`json
438
- { "name": "opacity", "type": "slider", "options": { "min": 0, "max": 100, "step": 10 } }
439
- \`\`\`
440
-
441
- ### date
442
- Date picker.
443
-
444
- ### info
445
- Display-only text for editors (not rendered in component).
446
-
447
- ## Svelte 5 Syntax
448
-
449
- Components use Svelte 5:
450
- - \`$state()\` for reactive variables
451
- - \`$derived()\` for computed values
452
- - \`$effect()\` for side effects
453
- - \`onclick={handler}\` not \`on:click={handler}\`
454
-
455
- ## Editor Context
456
-
457
- Check if component is running in the CMS editor:
458
- \`\`\`svelte
459
- let is_editor = $state(false)
460
-
461
- if (typeof window !== 'undefined') {
462
- is_editor = window.__PALA_CONTEXT__?.environment === 'editor'
463
- }
464
- \`\`\`
309
+ Primo workspace for local development. Each subdirectory under \`sites/\` is an independent Primo site.
310
+
311
+ The Primo MCP server (\`primo\`) is the source of truth, when present, for schema, validation, field types, and inline editing. Call \`list_docs\` first to see what's documented.
312
+
313
+ Without the MCP server, read \`sites/*/blocks/*/fields.yaml\` and \`sites/*/page-types/*/fields.yaml\` to infer schemas, and treat \`sites/*/pages/*.yaml\` page/section \`content:\` as the source of truth for rendered content (block \`content.yaml\` files are defaults only).
314
+
315
+ ## Layout
316
+
317
+ - \`server.yaml\` — workspace config and site groups
318
+ - \`sites/<name>/\` — individual sites (\`site.yaml\`, \`site/\`, \`blocks/\`, \`page-types/\`, \`pages/\`, \`.primo/\`)
319
+ - \`library/\` — shared library content
465
320
 
466
- Use this for:
467
- - Disabling fixed/sticky positioning
468
- - Skipping scroll/resize listeners
469
- - Showing placeholder content
321
+ ## Setup
470
322
 
471
- ## This Site
323
+ - \`primo dev\` — start the local CMS and dev server. Run from the workspace root.
324
+ - \`primo new [name]\` — scaffold a new site under \`sites/\`.
325
+ - File edits sync automatically while \`primo dev\` is running. Structural changes (block schema, component) may trigger a browser reload.
472
326
 
473
- ### Blocks
327
+ ## Source of truth
474
328
 
475
- - \`hero\` - Hero
329
+ - Editable source lives in \`sites/<name>/site.yaml\`, \`site/\`, \`blocks/\`, \`page-types/\`, and \`pages/\`.
330
+ - \`.primo/\` is generated local state (SQLite + uploads). Do not edit, do not commit.
331
+ - Do not patch the SQLite DB to fix schema or content issues. Edit the source files; the dev server reimports.
332
+ - If local state seems wrong, delete \`.primo/\` and let the dev server reimport from files.
476
333
 
477
- ### Page Types
334
+ ## Recovering overwritten files
478
335
 
479
- - \`default\` - Default
336
+ When \`primo dev\` syncs CMS changes to disk, the prior file content is copied to \`.primo/trash/\` before the overwrite or delete. Entries are kept for 7 days.
480
337
 
481
- ## Best Practices
338
+ If a file appears to have lost content after a sync (deleted entries, shrunken YAML lists, missing sections, removed files), check \`.primo/trash/\` for the most recent copy and restore with \`cp\`. The dev server also annotates the change log when a synced file shrinks or is deleted.
482
339
 
483
- ### Safe Field Access
340
+ ## IDs
484
341
 
485
- Always handle potentially undefined fields:
486
- \`\`\`svelte
487
- <!-- Images -->
488
- {#if hero_image?.url}
489
- <img src={hero_image.url} alt={hero_image.alt} />
490
- {/if}
342
+ - Top-level entities use system-owned \`_id\` keys (pages, sections, blocks, page types, fields).
343
+ - For blocks, \`_id\` lives in \`blocks/<key>/config.yaml\`. For page types, in \`page-types/<key>/config.yaml\`. The folder name is the stable reference key — editing \`name\` in \`config.yaml\` only changes the editor display label.
344
+ - When creating a new entity, omit the ID. The dev server generates and writes it back on first sync.
345
+ - Do not invent or hand-author IDs. Keep existing IDs stable when editing.
346
+ - Duplicate IDs are treated as conflicts; affected files may be skipped.
491
347
 
492
- <!-- Links -->
493
- <a href={cta?.url || '#'}>{cta?.label || 'Learn More'}</a>
348
+ ## Routing
494
349
 
495
- <!-- Repeaters -->
496
- {#each features || [] as feature}
497
- <div>{feature.title}</div>
498
- {/each}
499
- \`\`\`
350
+ - Page slugs come from the file path under \`pages/\`, not a \`slug:\` key.
351
+ - \`pages/index.yaml\` \`/\`, \`pages/about.yaml\` → \`/about\`, \`pages/about/team.yaml\` → \`/about/team\`.
352
+ - Do not add \`slug:\` to page files. It is ignored.
500
353
 
501
354
  ## Workflow
502
355
 
503
- 1. Run \`primo dev\` to start the local preview server
504
- 2. Edit blocks, pages, or site settings - changes auto-sync to dev server
505
- 3. Run \`primo push\` to deploy changes to a live server (if connected)
356
+ - When building out a new site, call \`get_docs('recommended-defaults')\` first for baseline fields and the wiring checklist.
357
+ - After editing a block file, call \`validate_block\`.
358
+ - After editing a page or page-type file, call \`validate_page\`.
359
+ - When creating a new block or page type, prefer \`scaffold_block\` / \`scaffold_page_type\`.
360
+ - When you create a reusable block, add its folder name to the relevant page type's \`allowed_blocks\` — otherwise it won't appear in the editor sidebar.
361
+ - For everything else, call \`get_docs\` with the relevant section.
362
+ - Block components are Svelte 5. If the Svelte MCP server is available, use \`mcp__svelte__svelte-autofixer\` after editing \`.svelte\` files.
363
+
364
+ ## Permission prompts
365
+
366
+ Your MCP client may prompt before each Primo tool call. All Primo MCP tools are read-only or return scaffolds — they don't modify your files. To skip the prompts, allowlist the \`primo\` server in your client's MCP settings (mechanism varies by client).
367
+
368
+ ---
369
+
370
+ # Project preferences
371
+
372
+ Fill in the sections below to give agents project-specific direction. Anything left blank is treated as "no preference."
373
+
374
+ ## Design direction
375
+
376
+ <!-- e.g. warm earth tones, generous whitespace, system fonts, minimal animation -->
377
+
378
+ ## Voice & tone
379
+
380
+ <!-- e.g. friendly but not casual, second-person, short sentences -->
381
+
382
+ ## SEO preferences
383
+
384
+ <!-- e.g. always mention city in seo_description, target ~150 chars, prefer action verbs in titles -->
385
+
386
+ ## Content guidelines
387
+
388
+ <!-- e.g. real customer names ok, no stock photos, prefer specific numbers over vague claims -->
389
+
390
+ ## Other
391
+
392
+ <!-- e.g. accessibility targets, performance budgets, browser support, anything else agents should know -->
506
393
  `;
507
394
  }
@@ -0,0 +1,7 @@
1
+ interface PullLibraryOptions {
2
+ server?: string;
3
+ output: string;
4
+ token?: string;
5
+ }
6
+ export declare function pull_library(options: PullLibraryOptions): Promise<void>;
7
+ export {};
@@ -0,0 +1,92 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import extract from 'extract-zip';
6
+ import { get_auth_token } from '../utils/auth.js';
7
+ async function detect_server() {
8
+ const ports = [3000, 8080, 5173];
9
+ for (const port of ports) {
10
+ try {
11
+ const url = `http://127.0.0.1:${port}`;
12
+ const response = await fetch(`${url}/api/health`, {
13
+ signal: AbortSignal.timeout(500)
14
+ });
15
+ if (response.ok) {
16
+ return url;
17
+ }
18
+ }
19
+ catch {
20
+ // Not running on this port
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+ export async function pull_library(options) {
26
+ const spinner = ora('Connecting...').start();
27
+ try {
28
+ let server;
29
+ if (options.server) {
30
+ server = options.server.replace(/\/+$/, '');
31
+ }
32
+ else {
33
+ spinner.text = 'Looking for local server...';
34
+ const detected = await detect_server();
35
+ server = (detected || 'http://localhost:3000').replace(/\/+$/, '');
36
+ spinner.text = `Using ${server}`;
37
+ }
38
+ const token = options.token || await get_auth_token(server);
39
+ const headers = {};
40
+ if (token) {
41
+ headers.Authorization = `Bearer ${token}`;
42
+ }
43
+ const output_dir = path.resolve(options.output);
44
+ await fs.mkdir(output_dir, { recursive: true });
45
+ spinner.text = 'Exporting library...';
46
+ const response = await fetch(`${server}/api/palacms/export-library`, {
47
+ headers
48
+ });
49
+ if (response.status === 404) {
50
+ spinner.fail('Shared library sync is not supported by this palacms server. Update the server before using `primo library pull`.');
51
+ process.exit(1);
52
+ }
53
+ if (!response.ok) {
54
+ const error = await response.text();
55
+ spinner.fail(`Export failed: ${error}`);
56
+ process.exit(1);
57
+ }
58
+ const zip_data = await response.arrayBuffer();
59
+ const temp_zip = path.join(output_dir, '.primo-library-export.zip');
60
+ await fs.writeFile(temp_zip, Buffer.from(zip_data));
61
+ spinner.text = 'Extracting library...';
62
+ await extract(temp_zip, { dir: output_dir });
63
+ await fs.unlink(temp_zip);
64
+ const summary = await count_library(path.join(output_dir, 'library'));
65
+ spinner.succeed(`Library exported to ${chalk.cyan(path.join(output_dir, 'library'))}`);
66
+ console.log('');
67
+ console.log(chalk.dim(' Library exported:'));
68
+ console.log(chalk.dim(` groups/ ${summary.groups}`));
69
+ console.log(chalk.dim(` blocks/ ${summary.blocks}`));
70
+ }
71
+ catch (error) {
72
+ spinner.fail(`Export failed: ${error instanceof Error ? error.message : error}`);
73
+ process.exit(1);
74
+ }
75
+ }
76
+ async function count_library(library_dir) {
77
+ const counts = { groups: 0, blocks: 0 };
78
+ try {
79
+ const entries = await fs.readdir(library_dir, { withFileTypes: true });
80
+ const groups = entries.filter((entry) => entry.isDirectory());
81
+ counts.groups = groups.length;
82
+ for (const group of groups) {
83
+ const group_dir = path.join(library_dir, group.name);
84
+ const block_entries = await fs.readdir(group_dir, { withFileTypes: true });
85
+ counts.blocks += block_entries.filter((entry) => entry.isDirectory()).length;
86
+ }
87
+ }
88
+ catch {
89
+ // No library directory exported
90
+ }
91
+ return counts;
92
+ }
@@ -1,6 +1,5 @@
1
1
  interface PullOptions {
2
2
  server?: string;
3
- site?: string;
4
3
  output: string;
5
4
  token?: string;
6
5
  }