primo-cli 0.1.6 → 0.1.8

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.
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { createHash, randomInt } from 'crypto';
4
4
  import chalk from 'chalk';
5
5
  import ora from 'ora';
6
- import { spawn } from 'child_process';
6
+ import { spawn, execFileSync } from 'child_process';
7
7
  import archiver from 'archiver';
8
8
  import extract from 'extract-zip';
9
9
  import { dump as dump_yaml, load as load_yaml } from 'js-yaml';
@@ -420,18 +420,51 @@ async function kill_port(port) {
420
420
  resolve(false);
421
421
  return;
422
422
  }
423
+ // Don't kill ourselves or our ancestors — lsof returns every PID
424
+ // holding the port, which on macOS includes parent processes that
425
+ // inherited the fd. SIGKILL'ing them takes this CLI down too.
426
+ const self_ancestry = get_self_ancestry();
427
+ let killed_any = false;
423
428
  for (const pid of pid_list) {
429
+ const pid_num = parseInt(pid, 10);
430
+ if (self_ancestry.has(pid_num))
431
+ continue;
424
432
  try {
425
- process.kill(parseInt(pid, 10), 'SIGKILL');
433
+ process.kill(pid_num, 'SIGKILL');
434
+ killed_any = true;
426
435
  }
427
436
  catch {
428
437
  // Process may have already exited
429
438
  }
430
439
  }
431
- resolve(true);
440
+ resolve(killed_any);
432
441
  });
433
442
  });
434
443
  }
444
+ function get_self_ancestry() {
445
+ const ancestry = new Set();
446
+ let pid = process.pid;
447
+ while (pid && pid > 1) {
448
+ ancestry.add(pid);
449
+ pid = get_parent_pid(pid);
450
+ if (pid && ancestry.has(pid))
451
+ break;
452
+ }
453
+ return ancestry;
454
+ }
455
+ function get_parent_pid(pid) {
456
+ try {
457
+ const result = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
458
+ encoding: 'utf-8',
459
+ stdio: ['ignore', 'pipe', 'ignore']
460
+ });
461
+ const parsed = parseInt(String(result).trim(), 10);
462
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
463
+ }
464
+ catch {
465
+ return undefined;
466
+ }
467
+ }
435
468
  // Fetch with timeout helper
436
469
  async function fetch_with_timeout(url, options = {}, timeout_ms = 10000) {
437
470
  const controller = new AbortController();
@@ -970,8 +1003,12 @@ export async function dev_server(options) {
970
1003
  console.log(chalk.dim(' Press Ctrl+C to stop'));
971
1004
  // Handle cleanup
972
1005
  const cleanup = async () => {
973
- if (is_cleaning_up)
974
- return;
1006
+ if (is_cleaning_up) {
1007
+ // Second Ctrl-C while we're still cleaning up — bail immediately
1008
+ // so the user isn't stuck waiting on an in-flight push/sync.
1009
+ console.log(chalk.dim('\n Force exit'));
1010
+ process.exit(130);
1011
+ }
975
1012
  is_cleaning_up = true;
976
1013
  console.log(chalk.dim('\n Shutting down...'));
977
1014
  for (const watcher of watchers) {
@@ -2066,7 +2103,18 @@ async function create_site_zip(dir, excluded_paths = new Set()) {
2066
2103
  }
2067
2104
  const site_json = path.join(dir, SITE_CONFIG_FILE);
2068
2105
  if (!is_excluded_path(SITE_CONFIG_FILE, excluded_paths)) {
2069
- archive.file(site_json, { name: SITE_CONFIG_FILE });
2106
+ // Strip `host` if present — `site.yaml` no longer carries it,
2107
+ // but older dirs pulled by previous CLI versions still do,
2108
+ // and the local CMS's /import endpoint persists whatever
2109
+ // host the zipped yaml declares, overwriting bootstrap's
2110
+ // *.localhost value and breaking preview routing.
2111
+ const sanitized = await read_site_yaml_without_host(site_json);
2112
+ if (sanitized !== null) {
2113
+ archive.append(sanitized, { name: SITE_CONFIG_FILE });
2114
+ }
2115
+ else {
2116
+ archive.file(site_json, { name: SITE_CONFIG_FILE });
2117
+ }
2070
2118
  }
2071
2119
  await archive.finalize();
2072
2120
  }
@@ -2076,6 +2124,22 @@ async function create_site_zip(dir, excluded_paths = new Set()) {
2076
2124
  })();
2077
2125
  });
2078
2126
  }
2127
+ async function read_site_yaml_without_host(site_yaml_path) {
2128
+ try {
2129
+ const raw = await fs.readFile(site_yaml_path, 'utf-8');
2130
+ const parsed = load_yaml(raw);
2131
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2132
+ return null;
2133
+ const data = parsed;
2134
+ if (!('host' in data))
2135
+ return null;
2136
+ const { host: _, ...rest } = data;
2137
+ return dump_yaml(rest, { lineWidth: -1, noRefs: true });
2138
+ }
2139
+ catch {
2140
+ return null;
2141
+ }
2142
+ }
2079
2143
  async function sync_from_cms(site_dir, api_url, config, server_config, workspace_dir, sync_policy = { mode: 'both' }) {
2080
2144
  const response = await fetch_with_timeout(`${api_url}/api/palacms/export/${config.site_id}`, {}, 15000);
2081
2145
  if (!response.ok)
@@ -87,9 +87,6 @@ export async function new_site(options) {
87
87
  const config = {
88
88
  name: display_name,
89
89
  site_id: generate_id(),
90
- // Leave host empty for local dev - dev.ts will generate coffee-shop.localhost:3000
91
- // Only set host if it looks like a real domain (has a dot)
92
- host: site_name.includes('.') ? site_name : '',
93
90
  group: 'default'
94
91
  };
95
92
  await write_site_config(site_dir, config);
@@ -48,6 +48,15 @@ async function read_configured_server_config(dir) {
48
48
  return null;
49
49
  }
50
50
  }
51
+ function is_remote_server(server) {
52
+ try {
53
+ const hostname = new URL(server).hostname;
54
+ return hostname !== 'localhost' && hostname !== '127.0.0.1' && hostname !== '::1';
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
51
60
  export async function pull_site(options) {
52
61
  const spinner = ora('Connecting...').start();
53
62
  try {
@@ -79,21 +88,36 @@ export async function pull_site(options) {
79
88
  }
80
89
  // Decide root dir: explicit --output wins; if cwd already has a configured
81
90
  // server.yaml, pull in place; otherwise nest under server hostname.
91
+ // Refuse to auto-create a `localhost/` folder — the user almost certainly
92
+ // didn't mean that. Require --output or --server explicitly in that case.
93
+ if (!options.output && !used_configured && !is_remote_server(server)) {
94
+ spinner.fail(`Resolved to ${server} but no output dir was given. ` +
95
+ `Pass --server <url> or --output <dir> to pull a local server.`);
96
+ process.exit(1);
97
+ }
82
98
  const root_dir = options.output
83
99
  ? path.resolve(options.output)
84
100
  : used_configured
85
101
  ? process.cwd()
86
102
  : path.resolve(server_folder_name(server));
87
- await fs.mkdir(root_dir, { recursive: true });
88
- // List all sites
103
+ // Validate the server is reachable BEFORE creating the output dir, so a
104
+ // failed pull doesn't leave an empty folder behind.
89
105
  spinner.text = 'Fetching sites...';
90
- const sites_response = await fetch(`${server}/api/collections/sites/records?perPage=200`, {
91
- headers
92
- });
106
+ let sites_response;
107
+ try {
108
+ sites_response = await fetch(`${server}/api/collections/sites/records?perPage=200`, {
109
+ headers
110
+ });
111
+ }
112
+ catch (error) {
113
+ spinner.fail(`Could not reach ${server}: ${error instanceof Error ? error.message : error}`);
114
+ process.exit(1);
115
+ }
93
116
  if (!sites_response.ok) {
94
117
  spinner.fail(`Failed to fetch sites (${sites_response.status})`);
95
118
  process.exit(1);
96
119
  }
120
+ await fs.mkdir(root_dir, { recursive: true });
97
121
  const sites_data = await sites_response.json();
98
122
  const sites = sites_data.items || [];
99
123
  if (sites.length === 0) {
@@ -127,13 +151,15 @@ export async function pull_site(options) {
127
151
  }
128
152
  // Fetch site groups so server.yaml has them
129
153
  const site_groups = await fetch_site_groups(server, headers);
130
- // Preserve any existing server.yaml (port, format, server URL) and just
131
- // refresh site_groups from the source of truth.
154
+ // Preserve any existing server.yaml (port, format) and refresh
155
+ // site_groups from the source of truth. Also persist the server URL
156
+ // when it's a remote so subsequent bare `primo pull` runs in this dir
157
+ // don't fall back to localhost detection.
132
158
  const existing = await read_configured_server_config(root_dir);
133
159
  await write_server_config(root_dir, {
134
160
  ...existing,
135
- port: existing?.port ?? 3000,
136
- site_groups: site_groups.length > 0 ? site_groups : existing?.site_groups
161
+ site_groups: site_groups.length > 0 ? site_groups : existing?.site_groups,
162
+ server: existing?.server ?? (is_remote_server(server) ? server : undefined)
137
163
  });
138
164
  spinner.succeed(`Server pulled to ${chalk.cyan(root_dir)}`);
139
165
  console.log('');
@@ -170,7 +196,6 @@ async function pull_one_site(server, headers, site, site_dir, spinner) {
170
196
  await fs.unlink(temp_zip);
171
197
  await write_site_config(site_dir, {
172
198
  name: site.name || 'Imported Site',
173
- host: site.host || '',
174
199
  site_id: site.id,
175
200
  server,
176
201
  group: site.group
@@ -300,8 +300,6 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id) {
300
300
  form.append('site_id', site_id);
301
301
  if (config?.name)
302
302
  form.append('name', config.name);
303
- if (config?.host)
304
- form.append('host', config.host);
305
303
  if (config?.group)
306
304
  form.append('group', config.group);
307
305
  form.append('file', new Blob([zip_buffer]), 'site.zip');
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
2
5
  import { Command } from 'commander';
3
6
  import chalk from 'chalk';
4
7
  import { init_workspace } from './commands/init.js';
@@ -12,11 +15,13 @@ import { login } from './commands/login.js';
12
15
  import { validate_site } from './commands/validate.js';
13
16
  import { deploy } from './commands/deploy.js';
14
17
  import { build_site } from './commands/build.js';
18
+ const pkg_path = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
19
+ const pkg_version = JSON.parse(fs.readFileSync(pkg_path, 'utf-8')).version;
15
20
  const program = new Command();
16
21
  program
17
22
  .name('primo')
18
23
  .description('Build sites visually, edit them anywhere')
19
- .version('0.1.3');
24
+ .version(pkg_version);
20
25
  // Top-level help: prepend a Deploy-vs-build-vs-push decision tree so first-time
21
26
  // users can pick a command without reading every description. Use 'before' so
22
27
  // it only renders for the root program, not subcommand help.
@@ -16,4 +16,5 @@ export declare function format_group_name(group_id: string): string;
16
16
  export declare function normalize_server_config(config: ServerConfig): ServerConfig;
17
17
  export declare function resolve_format_options(config: ServerConfig): FormatOptions;
18
18
  export declare function read_server_config(base_dir: string): Promise<ServerConfig>;
19
+ export declare const DEFAULT_PORT = 3000;
19
20
  export declare function write_server_config(base_dir: string, config: ServerConfig): Promise<void>;
@@ -44,6 +44,12 @@ export async function read_server_config(base_dir) {
44
44
  const config_data = await fs.readFile(get_server_config_path(base_dir), 'utf-8');
45
45
  return normalize_server_config(load_yaml(config_data));
46
46
  }
47
+ export const DEFAULT_PORT = 3000;
47
48
  export async function write_server_config(base_dir, config) {
48
- await fs.writeFile(get_server_config_path(base_dir), dump_yaml(normalize_server_config(config), { lineWidth: -1, noRefs: true }));
49
+ const normalized = normalize_server_config(config);
50
+ // Drop port from the file when it's the default — it's noise otherwise.
51
+ const for_write = normalized.port === DEFAULT_PORT
52
+ ? { ...normalized, port: undefined }
53
+ : normalized;
54
+ await fs.writeFile(get_server_config_path(base_dir), dump_yaml(for_write, { lineWidth: -1, noRefs: true }));
49
55
  }
@@ -1,7 +1,6 @@
1
1
  export interface SiteConfig {
2
2
  name: string;
3
3
  site_id: string;
4
- host?: string;
5
4
  server?: string;
6
5
  group?: string;
7
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {