primo-cli 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,6 +51,11 @@ export async function build_site(options) {
51
51
  // Find all pages
52
52
  const pages_dir = path.join(site_dir, 'pages');
53
53
  const page_files = await find_pages(pages_dir);
54
+ // Map each page's _id to its live URL so internal `page:` links resolve.
55
+ // The URL is derived from the page's file location (same convention the
56
+ // build uses to write output), not the slug, so a page-ref and a raw
57
+ // url pointing at the same page produce identical hrefs.
58
+ const page_url_map = await build_page_url_map(site_dir, page_files);
54
59
  spinner.text = `Building ${page_files.length} page${page_files.length !== 1 ? 's' : ''}...`;
55
60
  // Compile all blocks once and cache them
56
61
  const block_cache = new Map();
@@ -77,7 +82,8 @@ export async function build_site(options) {
77
82
  block_cache,
78
83
  layout_cache,
79
84
  page_type_head_cache,
80
- site_data
85
+ site_data,
86
+ page_url_map
81
87
  });
82
88
  if (result.error) {
83
89
  console.log(chalk.yellow(` Warning: ${page.name}: ${result.error}`));
@@ -140,7 +146,7 @@ async function load_page_type_head(site_dir, page_type, cache) {
140
146
  return content;
141
147
  }
142
148
  async function build_page(options) {
143
- const { page, page_path, site_dir, temp_dir, head_content, site_name, block_cache, layout_cache, page_type_head_cache, site_data } = options;
149
+ const { page, page_path, site_dir, temp_dir, head_content, site_name, block_cache, layout_cache, page_type_head_cache, site_data, page_url_map } = options;
144
150
  try {
145
151
  const page_build_id = safe_temp_id(page._id || page.id || page_path || page.name || 'page');
146
152
  // Load layout for this page type
@@ -155,9 +161,9 @@ async function build_page(options) {
155
161
  const page_type_head = await load_page_type_head(site_dir, page_type, page_type_head_cache);
156
162
  const combined_head_content = page_type_head ? `${head_content}\n${page_type_head}` : head_content;
157
163
  // Combine header + page sections + footer
158
- const header_sections = await resolve_layout_sections(layout.header || [], site_dir, site_data);
159
- const footer_sections = await resolve_layout_sections(layout.footer || [], site_dir, site_data);
160
- const page_sections = await resolve_page_sections(page.sections || [], site_dir, site_data);
164
+ const header_sections = await resolve_layout_sections(layout.header || [], site_dir, site_data, page_url_map);
165
+ const footer_sections = await resolve_layout_sections(layout.footer || [], site_dir, site_data, page_url_map);
166
+ const page_sections = await resolve_page_sections(page.sections || [], site_dir, site_data, page_url_map);
161
167
  const all_sections = [...header_sections, ...page_sections, ...footer_sections];
162
168
  if (all_sections.length === 0) {
163
169
  return { html: generate_empty_page(site_name, page.name, combined_head_content) };
@@ -448,7 +454,7 @@ async function load_layout(site_dir, page_type) {
448
454
  return {};
449
455
  }
450
456
  }
451
- async function resolve_layout_sections(sections, site_dir, site_data) {
457
+ async function resolve_layout_sections(sections, site_dir, site_data, page_url_map) {
452
458
  // For layout sections without content, load from block's content.yaml
453
459
  const resolved = [];
454
460
  for (const section of sections) {
@@ -462,20 +468,73 @@ async function resolve_layout_sections(sections, site_dir, site_data) {
462
468
  }
463
469
  // Resolve any site-field references in the content
464
470
  const resolved_content = await resolve_site_fields(site_dir, section.block, content, site_data);
465
- resolved.push({ ...section, content: resolved_content });
471
+ // Resolve internal page: links to URLs (walks nested repeaters/groups too)
472
+ resolved.push({ ...section, content: resolve_links(resolved_content, page_url_map) });
466
473
  }
467
474
  return resolved;
468
475
  }
469
- async function resolve_page_sections(sections, site_dir, site_data) {
476
+ async function resolve_page_sections(sections, site_dir, site_data, page_url_map) {
470
477
  // Resolve site-field references in page sections
471
478
  const resolved = [];
472
479
  for (const section of sections) {
473
480
  const content = section.content || {};
474
481
  const resolved_content = await resolve_site_fields(site_dir, section.block, content, site_data);
475
- resolved.push({ ...section, content: resolved_content });
482
+ // Resolve internal page: links to URLs (walks nested repeaters/groups too)
483
+ resolved.push({ ...section, content: resolve_links(resolved_content, page_url_map) });
476
484
  }
477
485
  return resolved;
478
486
  }
487
+ // Build a map of page _id -> live URL path. The URL is derived from the page
488
+ // file's location (the same convention used to write output HTML), so a
489
+ // `page:` reference resolves to exactly the path that page is deployed at.
490
+ async function build_page_url_map(site_dir, page_files) {
491
+ const map = new Map();
492
+ for (const page_file of page_files) {
493
+ try {
494
+ const page = load_yaml(await fs.readFile(page_file, 'utf-8'));
495
+ const id = page._id || page.id;
496
+ if (!id)
497
+ continue;
498
+ const page_path = get_page_path_from_file(site_dir, page_file);
499
+ map.set(id, page_path === '' ? '/' : `/${page_path}`);
500
+ }
501
+ catch {
502
+ // Skip unparseable page files; they'll surface elsewhere in the build.
503
+ }
504
+ }
505
+ return map;
506
+ }
507
+ // Recursively resolve internal `page:` links to their URL. Walks arbitrarily
508
+ // nested content (repeaters, groups, page-lists), so links inside a
509
+ // site-field-referenced repeater resolve the same as top-level link fields.
510
+ //
511
+ // A link value is any object carrying a `page` id. We look the id up in the
512
+ // page URL map and populate `url`:
513
+ // - known page id -> the page's live URL
514
+ // - missing/deleted id -> '' (degrades to href="#" downstream, never crashes)
515
+ // Objects with only `url` (raw/external links) are left untouched, and every
516
+ // other key on the link object (label, etc.) is preserved.
517
+ function resolve_links(value, page_url_map) {
518
+ if (Array.isArray(value)) {
519
+ return value.map((item) => resolve_links(item, page_url_map));
520
+ }
521
+ if (value && typeof value === 'object') {
522
+ const obj = value;
523
+ // A link with a page reference: resolve it to a URL.
524
+ if (typeof obj.page === 'string' && obj.page) {
525
+ const url = page_url_map.get(obj.page) ?? '';
526
+ return { ...obj, url };
527
+ }
528
+ // Otherwise recurse into every value (covers repeater arrays, groups,
529
+ // and the `{ link: {...} }` wrapper repeaters produce).
530
+ const result = {};
531
+ for (const [key, child] of Object.entries(obj)) {
532
+ result[key] = resolve_links(child, page_url_map);
533
+ }
534
+ return result;
535
+ }
536
+ return value;
537
+ }
479
538
  async function load_block_defaults(site_dir, block_name) {
480
539
  const content_path = path.join(site_dir, 'blocks', block_name, 'content.yaml');
481
540
  try {
@@ -2,7 +2,7 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import ora from 'ora';
5
- import { select, confirm } from '@inquirer/prompts';
5
+ import { select } from '@inquirer/prompts';
6
6
  import { execSync, spawn } from 'child_process';
7
7
  import { read_server_config, write_server_config, get_server_config_path, SERVER_CONFIG_FILE } from '../utils/server-config.js';
8
8
  import { read_site_config, get_site_config_path } from '../utils/site-config.js';
@@ -301,6 +301,30 @@ async function deploy_to_railway(inventory, auto_push) {
301
301
  spinner.fail('Failed to set up Railway project');
302
302
  throw error;
303
303
  }
304
+ // Attach a persistent volume at /app/pb_data BEFORE the first deploy. If the
305
+ // container starts without it, primo writes the SQLite db to ephemeral
306
+ // storage and it's lost on the next image change or restart. Mounting after
307
+ // data already exists starts from an empty volume, so ordering matters.
308
+ spinner.start('Ensuring persistent volume at /app/pb_data...');
309
+ try {
310
+ if (railway_volume_exists(inventory.root_dir, PB_DATA_MOUNT_PATH)) {
311
+ spinner.succeed('Persistent volume already mounted at /app/pb_data');
312
+ }
313
+ else {
314
+ execSync(`railway volume add --mount-path ${PB_DATA_MOUNT_PATH}`, {
315
+ cwd: inventory.root_dir,
316
+ stdio: 'ignore'
317
+ });
318
+ spinner.succeed('Created persistent volume at /app/pb_data');
319
+ }
320
+ }
321
+ catch {
322
+ // Volume creation can fail (e.g. plan limits, already-attached on another
323
+ // service). Don't abort the deploy — warn and fall back to the manual
324
+ // dashboard step so the user can still finish.
325
+ spinner.warn('Could not attach volume automatically — mount it manually');
326
+ console.log(chalk.dim(' Settings → Volumes → mount on /app/pb_data (size 1GB+)'));
327
+ }
304
328
  console.log('');
305
329
  console.log(chalk.dim('Building and deploying...'));
306
330
  console.log('');
@@ -323,10 +347,9 @@ async function deploy_to_railway(inventory, auto_push) {
323
347
  if (url) {
324
348
  await record_workspace_server(inventory.root_dir, url);
325
349
  }
326
- // Railway needs a manual volume mount before the server can persist
327
- // /app/pb_data. Print the instructions, wait for the user, then poll
328
- // readiness and push. If anything in that chain fails we fall back
329
- // to the old "next steps" message so the user can finish by hand.
350
+ // Volume is already attached (before deploy), so just wait for the
351
+ // server to come online and push. If anything fails we fall back to
352
+ // the "next steps" message so the user can finish by hand.
330
353
  const pushed = url
331
354
  ? await finish_railway_deploy(inventory, url, auto_push)
332
355
  : false;
@@ -336,30 +359,33 @@ async function deploy_to_railway(inventory, auto_push) {
336
359
  });
337
360
  }
338
361
  async function finish_railway_deploy(inventory, url, auto_push) {
339
- console.log('');
340
- console.log(chalk.bold(' One manual step on Railway'));
341
- console.log(chalk.dim(' Open the project in the Railway dashboard, then:'));
342
- console.log(chalk.dim(' Settings → Volumes → mount on /app/pb_data (size 1GB+)'));
343
- console.log('');
362
+ // The persistent volume is attached before deploy, so there's no manual
363
+ // dashboard step left — just wait for the server and push.
344
364
  if (!auto_push)
345
365
  return false;
346
- let confirmed = false;
366
+ const ready = await wait_for_ready(url);
367
+ if (!ready)
368
+ return false;
369
+ return await run_auto_push(inventory);
370
+ }
371
+ const PB_DATA_MOUNT_PATH = '/app/pb_data';
372
+ // Check whether the linked Railway project already has a volume mounted at the
373
+ // given path. The list JSON field name has varied across CLI versions, so we
374
+ // accept a few spellings rather than pinning one.
375
+ function railway_volume_exists(cwd, mount_path) {
347
376
  try {
348
- confirmed = await confirm({
349
- message: 'Mounted the volume? (press enter to upload your workspace)',
350
- default: true
377
+ const out = execSync('railway volume list --json', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
378
+ const volumes = JSON.parse(out);
379
+ if (!Array.isArray(volumes))
380
+ return false;
381
+ return volumes.some((v) => {
382
+ const path = v.mountPath ?? v.mount_path ?? v.mountpath;
383
+ return typeof path === 'string' && path.replace(/\/+$/, '') === mount_path.replace(/\/+$/, '');
351
384
  });
352
385
  }
353
386
  catch {
354
- // Ctrl+C / non-interactive — bail out, user can run primo push later.
355
387
  return false;
356
388
  }
357
- if (!confirmed)
358
- return false;
359
- const ready = await wait_for_ready(url);
360
- if (!ready)
361
- return false;
362
- return await run_auto_push(inventory);
363
389
  }
364
390
  async function try_railway_domain(cwd) {
365
391
  try {
@@ -411,13 +437,6 @@ function print_post_deploy_next_steps(provider, url, ctx) {
411
437
  }
412
438
  console.log(chalk.bold('Next steps'));
413
439
  console.log('');
414
- if (provider === 'railway' && !ctx.auto_push) {
415
- // User passed --no-push; remind them about the volume since we
416
- // skipped the prompt that would normally cover it.
417
- console.log(chalk.dim(' Railway needs one manual setting the CLI can\'t set for you:'));
418
- console.log(chalk.dim(' Settings → Volumes → mount on /app/pb_data (size 1GB+)'));
419
- console.log('');
420
- }
421
440
  console.log(chalk.dim(' Upload your workspace into the deployed server:'));
422
441
  console.log(chalk.dim(` primo push${url ? '' : ' -s <url>'}`));
423
442
  console.log(chalk.dim(' (first push bootstraps the sites; later pushes update them incrementally)'));
@@ -1921,6 +1921,14 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1921
1921
  if (bootstrap_response.ok) {
1922
1922
  try {
1923
1923
  const result = await bootstrap_response.json();
1924
+ // Bootstrap runs the same import as the regular path, so it
1925
+ // must write back created ids too — otherwise the very first
1926
+ // push never renames upload files to their canonical suffixed
1927
+ // names or rewrites symbolic refs, and later pushes keep
1928
+ // re-sending the un-suffixed names (the upload dup bug).
1929
+ if (result.created_ids) {
1930
+ await write_created_ids(site_dir, result.created_ids, server_config, workspace_dir);
1931
+ }
1924
1932
  warning_count = print_import_warnings(config.name, result.warnings);
1925
1933
  }
1926
1934
  catch {
@@ -3,4 +3,5 @@ interface LoginOptions {
3
3
  email?: string;
4
4
  }
5
5
  export declare function login(options: LoginOptions): Promise<void>;
6
+ export declare function authenticate_interactively(server: string, email?: string): Promise<string | null>;
6
7
  export {};
@@ -3,7 +3,7 @@ import ora from 'ora';
3
3
  import readline from 'readline';
4
4
  import fs from 'fs/promises';
5
5
  import { save_auth_token } from '../utils/auth.js';
6
- import { read_server_config, get_server_config_path } from '../utils/server-config.js';
6
+ import { read_server_config, get_server_config_path, normalize_server_url } from '../utils/server-config.js';
7
7
  export async function login(options) {
8
8
  let server_url = options.server;
9
9
  if (!server_url) {
@@ -29,7 +29,19 @@ export async function login(options) {
29
29
  console.log('');
30
30
  console.log(chalk.bold(`Logging in to ${server}`));
31
31
  console.log('');
32
- let email = options.email;
32
+ const token = await authenticate_interactively(server, options.email);
33
+ if (!token) {
34
+ process.exit(1);
35
+ }
36
+ console.log('');
37
+ console.log(chalk.dim(' Token saved to ~/.primo/tokens.json'));
38
+ console.log(chalk.dim(' You can now use `primo pull` and `primo push` without --token'));
39
+ }
40
+ // Prompt for credentials (email if not supplied, then password), authenticate
41
+ // against the server, and persist the token on success. Returns the token, or
42
+ // null if authentication failed. Shared by `login` and the auto-login prompt
43
+ // that `pull`/`push` fall back to when no token is cached.
44
+ export async function authenticate_interactively(server, email) {
33
45
  if (!email) {
34
46
  email = await prompt('Email: ');
35
47
  }
@@ -50,28 +62,18 @@ export async function login(options) {
50
62
  if (!response.ok) {
51
63
  const error = await response.json().catch(() => ({ message: 'Authentication failed' }));
52
64
  spinner.fail(`Login failed: ${error.message || 'Invalid credentials'}`);
53
- process.exit(1);
65
+ return null;
54
66
  }
55
67
  const data = await response.json();
56
68
  // Save the token
57
69
  await save_auth_token(server, data.token);
58
70
  spinner.succeed(`Logged in as ${chalk.cyan(data.record.email)}`);
59
- console.log('');
60
- console.log(chalk.dim(' Token saved to ~/.primo/tokens.json'));
61
- console.log(chalk.dim(' You can now use `primo pull` and `primo push` without --token'));
71
+ return data.token;
62
72
  }
63
73
  catch (error) {
64
74
  spinner.fail(`Login failed: ${error instanceof Error ? error.message : error}`);
65
- process.exit(1);
66
- }
67
- }
68
- function normalize_server_url(server) {
69
- // Add https:// if no protocol specified
70
- if (!server.startsWith('http://') && !server.startsWith('https://')) {
71
- server = `https://${server}`;
75
+ return null;
72
76
  }
73
- // Remove trailing slash
74
- return server.replace(/\/+$/, '');
75
77
  }
76
78
  function prompt(question) {
77
79
  const rl = readline.createInterface({
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import extract from 'extract-zip';
6
6
  import { get_auth_token } from '../utils/auth.js';
7
+ import { normalize_server_url } from '../utils/server-config.js';
7
8
  async function detect_server() {
8
9
  const ports = [3000, 8080, 5173];
9
10
  for (const port of ports) {
@@ -27,7 +28,7 @@ export async function pull_library(options) {
27
28
  try {
28
29
  let server;
29
30
  if (options.server) {
30
- server = options.server.replace(/\/+$/, '');
31
+ server = normalize_server_url(options.server);
31
32
  }
32
33
  else {
33
34
  spinner.text = 'Looking for local server...';
@@ -5,8 +5,9 @@ import ora from 'ora';
5
5
  import extract from 'extract-zip';
6
6
  import { dump as dump_yaml, load as load_yaml } from 'js-yaml';
7
7
  import { get_auth_token } from '../utils/auth.js';
8
+ import { authenticate_interactively } from './login.js';
8
9
  import { write_site_config } from '../utils/site-config.js';
9
- import { read_server_config, write_server_config } from '../utils/server-config.js';
10
+ import { read_server_config, write_server_config, normalize_server_url } from '../utils/server-config.js';
10
11
  async function detect_server() {
11
12
  const ports = [3000, 8080, 5173];
12
13
  for (const port of ports) {
@@ -64,12 +65,15 @@ export async function pull_site(options) {
64
65
  let server;
65
66
  let used_configured = false;
66
67
  if (options.server) {
67
- server = options.server.replace(/\/+$/, '');
68
+ server = normalize_server_url(options.server);
68
69
  }
69
70
  else {
70
71
  const configured = await read_configured_server(process.cwd());
71
72
  if (configured) {
72
- server = configured;
73
+ // A hand-edited server.yaml may hold a bare host; normalize it so
74
+ // is_remote_server() and the inline-login guard below behave the
75
+ // same as they do for a --server flag.
76
+ server = normalize_server_url(configured);
73
77
  used_configured = true;
74
78
  spinner.text = `Using ${server}`;
75
79
  }
@@ -80,8 +84,21 @@ export async function pull_site(options) {
80
84
  spinner.text = `Using ${server}`;
81
85
  }
82
86
  }
83
- // Auth (optional for local)
84
- const token = options.token || await get_auth_token(server);
87
+ // Auth (optional for local). For a remote server with no cached token,
88
+ // prompt for login inline instead of bailing out — the user almost
89
+ // always wants to authenticate and continue rather than re-run.
90
+ let token = options.token || await get_auth_token(server);
91
+ if (!token && is_remote_server(server)) {
92
+ spinner.stop();
93
+ console.log('');
94
+ console.log(chalk.dim(` Not logged in to ${server}. Log in to continue.`));
95
+ console.log('');
96
+ token = await authenticate_interactively(server);
97
+ if (!token) {
98
+ process.exit(1);
99
+ }
100
+ spinner.start('Fetching sites...');
101
+ }
85
102
  const headers = {};
86
103
  if (token) {
87
104
  headers['Authorization'] = `Bearer ${token}`;
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import archiver from 'archiver';
6
6
  import { get_auth_token } from '../utils/auth.js';
7
+ import { normalize_server_url } from '../utils/server-config.js';
7
8
  function is_local_server(server) {
8
9
  try {
9
10
  const url = new URL(server);
@@ -28,7 +29,7 @@ export async function push_library(options) {
28
29
  spinner.fail(`Library directory not found at ${chalk.cyan(library_dir)}.`);
29
30
  process.exit(1);
30
31
  }
31
- const server = options.server?.replace(/\/+$/, '');
32
+ const server = options.server ? normalize_server_url(options.server) : undefined;
32
33
  if (!server) {
33
34
  spinner.fail('Server URL required. Pass it as the first argument or use --server.');
34
35
  process.exit(1);
@@ -5,7 +5,7 @@ import ora from 'ora';
5
5
  import archiver from 'archiver';
6
6
  import { get_auth_token } from '../utils/auth.js';
7
7
  import { read_site_config, get_site_config_path, SITE_CONFIG_FILE } from '../utils/site-config.js';
8
- import { get_server_config_path, read_server_config } from '../utils/server-config.js';
8
+ import { get_server_config_path, read_server_config, normalize_server_url } from '../utils/server-config.js';
9
9
  async function path_exists(p) {
10
10
  try {
11
11
  await fs.access(p);
@@ -133,7 +133,8 @@ async function print_push_dry_run(root_dir, has_site_yaml, has_server_yaml, opti
133
133
  }
134
134
  library_present = await path_exists(path.join(root_dir, 'library'));
135
135
  }
136
- const server = (options.server || inferred_server)?.replace(/\/+$/, '');
136
+ const server_raw = options.server || inferred_server;
137
+ const server = server_raw ? normalize_server_url(server_raw) : undefined;
137
138
  console.log(` Target server: ${chalk.cyan(server || '(not set — pass --server or set in site.yaml)')}`);
138
139
  console.log('');
139
140
  console.log(chalk.bold(' Will sync:'));
@@ -243,7 +244,8 @@ async function push_single_site(site_dir, options, spinner) {
243
244
  catch {
244
245
  // No config file, must provide options
245
246
  }
246
- const server = (options.server || config?.server)?.replace(/\/+$/, '');
247
+ const server_raw = options.server || config?.server;
248
+ const server = server_raw ? normalize_server_url(server_raw) : undefined;
247
249
  const site_id = options.site || config?.site_id;
248
250
  if (!server) {
249
251
  throw new Error(`Server URL required. Use --server or add server field to ${SITE_CONFIG_FILE}.`);
@@ -362,7 +364,7 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id, gr
362
364
  }
363
365
  async function push_library_dir(root_dir, options, spinner) {
364
366
  // Resolve server: --server > any site.yaml's server (they all point at the same server)
365
- let server = options.server?.replace(/\/+$/, '');
367
+ let server = options.server ? normalize_server_url(options.server) : undefined;
366
368
  if (!server) {
367
369
  const sites_root = path.join(root_dir, 'sites');
368
370
  if (await path_exists(sites_root)) {
@@ -12,6 +12,7 @@ export interface ServerConfig {
12
12
  }
13
13
  export declare const SERVER_CONFIG_FILE = "server.yaml";
14
14
  export declare function get_server_config_path(base_dir: string): string;
15
+ export declare function normalize_server_url(server: string): string;
15
16
  export declare function format_group_name(group_id: string): string;
16
17
  export declare function normalize_server_config(config: ServerConfig): ServerConfig;
17
18
  export declare function resolve_format_options(config: ServerConfig): FormatOptions;
@@ -6,6 +6,17 @@ export const SERVER_CONFIG_FILE = 'server.yaml';
6
6
  export function get_server_config_path(base_dir) {
7
7
  return path.join(base_dir, SERVER_CONFIG_FILE);
8
8
  }
9
+ // Normalize a user-supplied server value: prepend a protocol when missing and
10
+ // strip trailing slashes. Bare localhost/127.0.0.1 stay on http:// (local dev);
11
+ // everything else gets https://.
12
+ export function normalize_server_url(server) {
13
+ let normalized = server.trim();
14
+ if (!/^https?:\/\//.test(normalized)) {
15
+ const is_local = /^(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?(\/|$)/.test(normalized);
16
+ normalized = `${is_local ? 'http' : 'https'}://${normalized}`;
17
+ }
18
+ return normalized.replace(/\/+$/, '');
19
+ }
9
20
  export function format_group_name(group_id) {
10
21
  if (!group_id.trim())
11
22
  return 'Default';
@@ -0,0 +1,34 @@
1
+ export declare class UploadsUnsupportedError extends Error {
2
+ constructor();
3
+ }
4
+ export declare class SiteNotFoundError extends Error {
5
+ constructor();
6
+ }
7
+ export interface LocalUpload {
8
+ filename: string;
9
+ abs_path: string;
10
+ hash: string;
11
+ }
12
+ export declare function collect_local_uploads(site_dir: string): Promise<LocalUpload[]>;
13
+ export interface UploadPutResult {
14
+ filename: string;
15
+ id: string;
16
+ canonical: string;
17
+ hash: string;
18
+ changed: boolean;
19
+ }
20
+ export interface SyncUploadsOptions {
21
+ server: string;
22
+ site_id: string;
23
+ token: string | undefined;
24
+ site_dir: string;
25
+ on_progress?: (done: number, total: number) => void;
26
+ concurrency?: number;
27
+ }
28
+ export interface SyncUploadsResult {
29
+ total: number;
30
+ uploaded: number;
31
+ skipped: number;
32
+ results: UploadPutResult[];
33
+ }
34
+ export declare function sync_uploads(opts: SyncUploadsOptions): Promise<SyncUploadsResult>;
@@ -0,0 +1,148 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ // Incremental upload sync. Instead of packing every binary into the import
5
+ // zip — which made large-media sites exceed the hosting proxy's request
6
+ // timeout on a single synchronous /import call — we push uploads out of band:
7
+ // hash them locally, ask the server which hashes it's missing, and PUT only
8
+ // those, one small request each.
9
+ //
10
+ // Servers predating these endpoints answer /check with 404; callers detect
11
+ // that via UploadsUnsupportedError and fall back to shipping uploads in the
12
+ // zip (the legacy path).
13
+ export class UploadsUnsupportedError extends Error {
14
+ constructor() {
15
+ super('server does not support incremental uploads');
16
+ this.name = 'UploadsUnsupportedError';
17
+ }
18
+ }
19
+ // The site doesn't exist on the server yet — its media can't be pushed until
20
+ // it's created (via bootstrap). Distinct from UploadsUnsupportedError so the
21
+ // caller routes to bootstrap instead of the legacy zip-uploads path.
22
+ export class SiteNotFoundError extends Error {
23
+ constructor() {
24
+ super('site not found on server');
25
+ this.name = 'SiteNotFoundError';
26
+ }
27
+ }
28
+ // PocketBase's router emits this exact message when no route matches. We use
29
+ // it to tell "endpoint missing" (old server → unsupported) apart from a
30
+ // handler-level 404 like "Site not found" (new server, site absent).
31
+ const ROUTER_NOT_FOUND_MESSAGE = "The requested resource wasn't found.";
32
+ // Classify a 404 body: true = the ROUTE is missing (old server), so uploads
33
+ // are unsupported; false = a handler 404 (e.g. site not found).
34
+ async function is_route_missing(res) {
35
+ try {
36
+ const body = (await res.clone().json());
37
+ return body?.message === ROUTER_NOT_FOUND_MESSAGE;
38
+ }
39
+ catch {
40
+ // Non-JSON 404 → treat as route missing (safest: fall back to zip).
41
+ return true;
42
+ }
43
+ }
44
+ const UPLOADS_DIR = 'uploads';
45
+ const MANIFEST_FILE = '.manifest.json';
46
+ function sha256_hex(buf) {
47
+ return crypto.createHash('sha256').update(buf).digest('hex');
48
+ }
49
+ // Enumerate uploads/ as a flat set of bare filenames, mirroring the server's
50
+ // zip-import filter: skip dotfiles (the manifest lives there) and anything
51
+ // nested. Hashes are read from bytes; a manifest with matching hashes could
52
+ // let us skip re-hashing unchanged files, but hashing local disk is cheap
53
+ // next to the network round-trip we're optimizing, so we always hash.
54
+ export async function collect_local_uploads(site_dir) {
55
+ const dir = path.join(site_dir, UPLOADS_DIR);
56
+ let entries;
57
+ try {
58
+ entries = await fs.readdir(dir);
59
+ }
60
+ catch {
61
+ return []; // no uploads/ folder
62
+ }
63
+ const uploads = [];
64
+ for (const name of entries) {
65
+ if (name.startsWith('.') || name.includes('/'))
66
+ continue;
67
+ const abs_path = path.join(dir, name);
68
+ const stat = await fs.stat(abs_path);
69
+ if (!stat.isFile())
70
+ continue;
71
+ const buf = await fs.readFile(abs_path);
72
+ uploads.push({ filename: name, abs_path, hash: sha256_hex(buf) });
73
+ }
74
+ return uploads;
75
+ }
76
+ async function post_json(url, token, body) {
77
+ const headers = { 'Content-Type': 'application/json' };
78
+ if (token)
79
+ headers['Authorization'] = `Bearer ${token}`;
80
+ return fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
81
+ }
82
+ // Ask the server which of the given hashes it does not already hold.
83
+ // Throws UploadsUnsupportedError on a 404 (old server without the endpoint).
84
+ async function check_missing(server, site_id, token, hashes) {
85
+ const res = await post_json(`${server}/api/primo/uploads/${site_id}/check`, token, { hashes });
86
+ if (res.status === 404) {
87
+ throw (await is_route_missing(res)) ? new UploadsUnsupportedError() : new SiteNotFoundError();
88
+ }
89
+ if (!res.ok)
90
+ throw new Error(await res.text());
91
+ const data = (await res.json());
92
+ return new Set(data.missing || []);
93
+ }
94
+ // PUT a single upload. Returns the server's record id + canonical name so the
95
+ // caller can reconcile local state (rename to the suffixed name, rewrite refs).
96
+ async function put_upload(server, site_id, token, up) {
97
+ const buf = await fs.readFile(up.abs_path);
98
+ const form = new FormData();
99
+ form.append('filename', up.filename);
100
+ form.append('file', new Blob([buf]), up.filename);
101
+ const headers = {};
102
+ if (token)
103
+ headers['Authorization'] = `Bearer ${token}`;
104
+ const res = await fetch(`${server}/api/primo/uploads/${site_id}/put`, {
105
+ method: 'POST',
106
+ headers,
107
+ body: form
108
+ });
109
+ if (res.status === 404) {
110
+ throw (await is_route_missing(res)) ? new UploadsUnsupportedError() : new SiteNotFoundError();
111
+ }
112
+ if (!res.ok)
113
+ throw new Error(await res.text());
114
+ const data = (await res.json());
115
+ return { filename: up.filename, id: data.id, canonical: data.canonical, hash: data.hash, changed: data.changed };
116
+ }
117
+ // Sync uploads incrementally. Returns counts + per-file results, or throws
118
+ // UploadsUnsupportedError if the server lacks the endpoints (caller falls back
119
+ // to the legacy zip path).
120
+ export async function sync_uploads(opts) {
121
+ const { server, site_id, token, site_dir } = opts;
122
+ const concurrency = Math.max(1, opts.concurrency ?? 4);
123
+ const local = await collect_local_uploads(site_dir);
124
+ if (local.length === 0) {
125
+ return { total: 0, uploaded: 0, skipped: 0, results: [] };
126
+ }
127
+ // One small request tells us the exact set to send. Dedup by hash so
128
+ // identical files (different names) are only weighed once server-side.
129
+ const missing_hashes = await check_missing(server, site_id, token, local.map((u) => u.hash));
130
+ const to_send = local.filter((u) => missing_hashes.has(u.hash));
131
+ const skipped = local.length - to_send.length;
132
+ const results = [];
133
+ let done = 0;
134
+ // Simple bounded worker pool over the send list.
135
+ let cursor = 0;
136
+ async function worker() {
137
+ while (cursor < to_send.length) {
138
+ const idx = cursor++;
139
+ const r = await put_upload(server, site_id, token, to_send[idx]);
140
+ results.push(r);
141
+ done++;
142
+ opts.on_progress?.(done, to_send.length);
143
+ }
144
+ }
145
+ const workers = Array.from({ length: Math.min(concurrency, to_send.length) }, () => worker());
146
+ await Promise.all(workers);
147
+ return { total: local.length, uploaded: to_send.length, skipped, results };
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {