primo-cli 0.1.11 → 0.1.13

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.
@@ -230,10 +230,10 @@ async function check_provider_auth(provider) {
230
230
  // Bump to a release tag (:v3.0.0) when primocms cuts a stable release.
231
231
  const PRIMO_SERVER_IMAGE = 'ghcr.io/primocms/primo:main';
232
232
  async function generate_dockerfile(inventory) {
233
- // One-line Dockerfile: pull the published palacms image and run it
233
+ // One-line Dockerfile: pull the published primo image and run it
234
234
  // unchanged. Workspace data (server.yaml, sites/, library/) is uploaded
235
- // after deploy via `primo push`, which calls /api/palacms/bootstrap on
236
- // first push (server with no sites) and /api/palacms/import/<id> on
235
+ // after deploy via `primo push`, which calls /api/primo/bootstrap on
236
+ // first push (server with no sites) and /api/primo/import/<id> on
237
237
  // subsequent pushes. The volume mounted at /app/pb_data persists the
238
238
  // SQLite database between restarts.
239
239
  void inventory;
@@ -255,7 +255,7 @@ EXPOSE 8080
255
255
  }
256
256
  async function generate_fly_toml(inventory) {
257
257
  const app_name = workspace_app_name(inventory.root_dir);
258
- // palacms binds 0.0.0.0:8080 in its CMD; mount /app/pb_data on a persistent
258
+ // primo binds 0.0.0.0:8080 in its CMD; mount /app/pb_data on a persistent
259
259
  // volume so the SQLite db + uploads survive restarts. Auto-start/stop keeps
260
260
  // the small instance free-tier-friendly.
261
261
  const fly_toml = `app = "${app_name}"
@@ -33,7 +33,7 @@ let is_importing_library = false;
33
33
  let has_pending_library_local_changes = false;
34
34
  let site_sync_baselines = new Map();
35
35
  // Tracks the last set of conflict paths logged per site so we don't reprint
36
- // the same conflict block every pull cycle when palacms's serialization
36
+ // the same conflict block every pull cycle when primo's serialization
37
37
  // keeps producing the same divergence (e.g. data-key mangling, key reorder).
38
38
  const last_logged_conflicts = new Map();
39
39
  // Track files written by sync to prevent watcher from re-pushing them.
@@ -254,7 +254,7 @@ function hash_snapshot_content(contents) {
254
254
  function snapshot_value(snapshot, file_path) {
255
255
  return snapshot.has(file_path) ? snapshot.get(file_path) : null;
256
256
  }
257
- // Reads a file but treats ENOENT as a soft miss — palacms' export step can
257
+ // Reads a file but treats ENOENT as a soft miss — primo' export step can
258
258
  // reshape the on-disk layout (e.g. promoting pages/foo.yaml to
259
259
  // pages/foo/index.yaml when a child route is added) between when a directory
260
260
  // listing is captured and when each file is read. The vanished file isn't an
@@ -273,7 +273,7 @@ async function read_file_or_vanish(full_path, label) {
273
273
  }
274
274
  // A path is "in conflict" when local has content that differs from CMS AND
275
275
  // the local content represents a real user change — not just CMS-side
276
- // serialization noise (palacms re-emits YAML with normalized key order,
276
+ // serialization noise (primo re-emits YAML with normalized key order,
277
277
  // ISO-coerced dates, etc., so the CMS export legitimately differs from a
278
278
  // freshly-scaffolded file forever, and we don't want to scream about that
279
279
  // every pull cycle).
@@ -369,7 +369,7 @@ async function collect_directory_snapshot(current_dir, relative_dir, snapshot, o
369
369
  }
370
370
  }
371
371
  async function fetch_cms_site_snapshot(site_dir, api_url, config, server_config, workspace_dir, temp_name) {
372
- const response = await fetch_with_timeout(`${api_url}/api/palacms/export/${config.site_id}`, {}, 15000);
372
+ const response = await fetch_with_timeout(`${api_url}/api/primo/export/${config.site_id}`, {}, 15000);
373
373
  if (!response.ok)
374
374
  return null;
375
375
  const temp_dir = path.join(site_dir, '.primo', temp_name);
@@ -572,19 +572,19 @@ export async function dev_server(options) {
572
572
  }
573
573
  }
574
574
  // Ensure binary is installed
575
- spinner.text = 'Checking palacms...';
575
+ spinner.text = 'Checking primo...';
576
576
  const binary_path = await ensure_binary();
577
577
  // Create data directory in project folder
578
578
  const data_dir = await ensure_data_dir(base_dir);
579
579
  spinner.text = 'Starting CMS...';
580
580
  // Start the CMS binary with dev mode enabled. PRIMO_AUTHOR_MODE
581
- // tells palacms which sync mode the CLI is running in so the CMS
581
+ // tells primo which sync mode the CLI is running in so the CMS
582
582
  // UI can gate its editable surfaces accordingly (read-only when
583
583
  // the CLI is in --author files, since CMS edits would be discarded
584
584
  // before they ever round-trip to disk).
585
585
  cms_process = spawn(binary_path, ['serve', '--http', `127.0.0.1:${port}`, '--dir', data_dir], {
586
586
  stdio: ['pipe', 'pipe', 'pipe'],
587
- env: { ...process.env, PALA_DEV_MODE: '1', PRIMO_AUTHOR_MODE: sync_policy.mode }
587
+ env: { ...process.env, PRIMO_DEV_MODE: '1', PRIMO_AUTHOR_MODE: sync_policy.mode }
588
588
  });
589
589
  // Capture stderr for errors
590
590
  let stderr_output = '';
@@ -1119,11 +1119,11 @@ async function register_primo_mcp_server(base_dir) {
1119
1119
  if (mcp_servers.primo !== undefined) {
1120
1120
  return null;
1121
1121
  }
1122
- const local_mcp = process.env.PRIMO_MCP_LOCAL
1123
- ?? '/Users/mateo/Desktop/primo/primo-mcp/dist/index.js';
1122
+ // Default to the published package; PRIMO_MCP_LOCAL opts into a local dist build.
1123
+ const local_mcp = process.env.PRIMO_MCP_LOCAL;
1124
1124
  mcp_servers.primo = local_mcp
1125
1125
  ? { command: 'node', args: [local_mcp] }
1126
- : { command: 'npx', args: ['-y', '@primo/mcp'] };
1126
+ : { command: 'npx', args: ['-y', 'primo-mcp'] };
1127
1127
  config.mcpServers = mcp_servers;
1128
1128
  // Claude Code reads project-root .mcp.json. .primo/ is gitignored local
1129
1129
  // state, so the discoverable root file is the right registration target.
@@ -1251,7 +1251,7 @@ function change_requires_reload(_dir, _filename) {
1251
1251
  return true;
1252
1252
  }
1253
1253
  async function request_browser_reload(api_url) {
1254
- await fetch_with_timeout(`${api_url}/api/palacms/dev/reload`, {
1254
+ await fetch_with_timeout(`${api_url}/api/primo/dev/reload`, {
1255
1255
  method: 'POST'
1256
1256
  }, 5000);
1257
1257
  }
@@ -1870,7 +1870,7 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1870
1870
  const import_form = new FormData();
1871
1871
  import_form.append('file', new Blob([zip_buffer]), 'site.zip');
1872
1872
  const request_started = Date.now();
1873
- const import_response = await fetch_with_timeout(`${api_url}/api/palacms/import/${site_id}`, {
1873
+ const import_response = await fetch_with_timeout(`${api_url}/api/primo/import/${site_id}`, {
1874
1874
  method: 'POST',
1875
1875
  body: import_form
1876
1876
  }, 300000);
@@ -1912,7 +1912,7 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1912
1912
  form_data.append('file', new Blob([zip_buffer]), 'site.zip');
1913
1913
  try {
1914
1914
  const bootstrap_started = Date.now();
1915
- const bootstrap_response = await fetch_with_timeout(`${api_url}/api/palacms/bootstrap`, {
1915
+ const bootstrap_response = await fetch_with_timeout(`${api_url}/api/primo/bootstrap`, {
1916
1916
  method: 'POST',
1917
1917
  body: form_data
1918
1918
  }, 300000); // 300s timeout for imports
@@ -1944,7 +1944,7 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1944
1944
  const import_form = new FormData();
1945
1945
  import_form.append('file', new Blob([zip_buffer]), 'site.zip');
1946
1946
  const import_started = Date.now();
1947
- const import_response = await fetch_with_timeout(`${api_url}/api/palacms/import/${site_id}`, {
1947
+ const import_response = await fetch_with_timeout(`${api_url}/api/primo/import/${site_id}`, {
1948
1948
  method: 'POST',
1949
1949
  body: import_form
1950
1950
  }, 300000); // 300s timeout for imports
@@ -2016,13 +2016,13 @@ async function import_library_files(base_dir, api_url, delete_group_ids = [], de
2016
2016
  }));
2017
2017
  }
2018
2018
  const request_started = Date.now();
2019
- const response = await fetch_with_timeout(`${api_url}/api/palacms/import-library`, {
2019
+ const response = await fetch_with_timeout(`${api_url}/api/primo/import-library`, {
2020
2020
  method: 'POST',
2021
2021
  body: form_data
2022
2022
  }, 120000);
2023
2023
  const request_ms = Date.now() - request_started;
2024
2024
  if (response.status === 404) {
2025
- throw new Error('Shared library sync is not supported by the current palacms binary/server. Rebuild or update palacms to use library sync.');
2025
+ throw new Error('Shared library sync is not supported by the current primo binary/server. Rebuild or update primo to use library sync.');
2026
2026
  }
2027
2027
  if (!response.ok) {
2028
2028
  const error_text = await response.text();
@@ -2146,7 +2146,7 @@ async function create_site_zip(dir, excluded_paths = new Set()) {
2146
2146
  });
2147
2147
  }
2148
2148
  async function sync_from_cms(site_dir, api_url, config, server_config, workspace_dir, sync_policy = { mode: 'both' }) {
2149
- const response = await fetch_with_timeout(`${api_url}/api/palacms/export/${config.site_id}`, {}, 15000);
2149
+ const response = await fetch_with_timeout(`${api_url}/api/primo/export/${config.site_id}`, {}, 15000);
2150
2150
  if (!response.ok)
2151
2151
  return;
2152
2152
  const zip_data = await response.arrayBuffer();
@@ -2240,9 +2240,9 @@ async function sync_from_cms(site_dir, api_url, config, server_config, workspace
2240
2240
  }
2241
2241
  }
2242
2242
  async function sync_library_from_cms(base_dir, api_url) {
2243
- const response = await fetch_with_timeout(`${api_url}/api/palacms/export-library`, {}, 15000);
2243
+ const response = await fetch_with_timeout(`${api_url}/api/primo/export-library`, {}, 15000);
2244
2244
  if (response.status === 404) {
2245
- throw new Error('Shared library sync is not supported by the current palacms binary/server. Rebuild or update palacms to use library sync.');
2245
+ throw new Error('Shared library sync is not supported by the current primo binary/server. Rebuild or update primo to use library sync.');
2246
2246
  }
2247
2247
  if (!response.ok)
2248
2248
  return;
@@ -107,10 +107,14 @@ allowed_blocks:
107
107
  `);
108
108
  await fs.writeFile(path.join(site_dir, 'page-types', 'default', 'fields.yaml'), '[]\n');
109
109
  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
110
- # the same header/footer across all pages of this type.
110
+ # the same header/footer across all pages of this type. Body sections are
111
+ # seeded onto each newly created page of this type (and locked when the type
112
+ # has no allowed_blocks).
111
113
  #
112
114
  # header:
113
115
  # - block: site-header
116
+ # body:
117
+ # - block: hero
114
118
  # footer:
115
119
  # - block: site-footer
116
120
  `);
@@ -43,11 +43,11 @@ export async function pull_library(options) {
43
43
  const output_dir = path.resolve(options.output || '.');
44
44
  await fs.mkdir(output_dir, { recursive: true });
45
45
  spinner.text = 'Exporting library...';
46
- const response = await fetch(`${server}/api/palacms/export-library`, {
46
+ const response = await fetch(`${server}/api/primo/export-library`, {
47
47
  headers
48
48
  });
49
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`.');
50
+ spinner.fail('Shared library sync is not supported by this primo server. Update the server before using `primo library pull`.');
51
51
  process.exit(1);
52
52
  }
53
53
  if (!response.ok) {
@@ -183,7 +183,7 @@ export async function pull_site(options) {
183
183
  async function pull_one_site(server, headers, site, site_dir, spinner) {
184
184
  await fs.mkdir(site_dir, { recursive: true });
185
185
  spinner.text = `Exporting ${site.name}...`;
186
- const response = await fetch(`${server}/api/palacms/export/${site.id}`, { headers });
186
+ const response = await fetch(`${server}/api/primo/export/${site.id}`, { headers });
187
187
  if (!response.ok) {
188
188
  const error = await response.text();
189
189
  throw new Error(`Export failed for ${site.name}: ${error}`);
@@ -205,7 +205,7 @@ async function pull_one_site(server, headers, site, site_dir, spinner) {
205
205
  }
206
206
  async function pull_library_into(server, headers, root_dir, spinner) {
207
207
  spinner.start('Pulling library...');
208
- const response = await fetch(`${server}/api/palacms/export-library`, { headers });
208
+ const response = await fetch(`${server}/api/primo/export-library`, { headers });
209
209
  if (response.status === 404) {
210
210
  spinner.warn('Library export not supported by this server — skipping');
211
211
  return false;
@@ -47,13 +47,13 @@ export async function push_library(options) {
47
47
  if (token) {
48
48
  headers.Authorization = `Bearer ${token}`;
49
49
  }
50
- const response = await fetch(`${server}/api/palacms/import-library`, {
50
+ const response = await fetch(`${server}/api/primo/import-library`, {
51
51
  method: 'POST',
52
52
  headers,
53
53
  body: form_data
54
54
  });
55
55
  if (response.status === 404) {
56
- spinner.fail('Shared library sync is not supported by this palacms server. Update the server before using `primo library push`.');
56
+ spinner.fail('Shared library sync is not supported by this primo server. Update the server before using `primo library push`.');
57
57
  process.exit(1);
58
58
  }
59
59
  if (!response.ok) {
@@ -275,8 +275,8 @@ async function push_single_site(site_dir, options, spinner) {
275
275
  throw new Error(bootstrap_result.error);
276
276
  }
277
277
  const endpoint = options.preview
278
- ? `${server}/api/palacms/import/${site_id}/preview`
279
- : `${server}/api/palacms/import/${site_id}`;
278
+ ? `${server}/api/primo/import/${site_id}/preview`
279
+ : `${server}/api/primo/import/${site_id}`;
280
280
  spinner.text = options.preview ? 'Previewing changes...' : 'Pushing changes...';
281
281
  const form_data = new FormData();
282
282
  form_data.append('file', new Blob([zip_buffer]), 'site.zip');
@@ -288,7 +288,7 @@ async function push_single_site(site_dir, options, spinner) {
288
288
  body: form_data
289
289
  });
290
290
  // 404 from import means the site doesn't exist on the server yet. On a
291
- // freshly-deployed server we can fall back to /api/palacms/bootstrap,
291
+ // freshly-deployed server we can fall back to /api/primo/bootstrap,
292
292
  // which creates the site and ingests the zip in one shot. Bootstrap is
293
293
  // only available when the server has zero sites — past the first site,
294
294
  // new sites must be created via the dashboard UI.
@@ -343,7 +343,7 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id, gr
343
343
  const headers = {};
344
344
  if (token)
345
345
  headers['Authorization'] = `Bearer ${token}`;
346
- const response = await fetch(`${server}/api/palacms/bootstrap`, {
346
+ const response = await fetch(`${server}/api/primo/bootstrap`, {
347
347
  method: 'POST',
348
348
  headers,
349
349
  body: form
@@ -399,7 +399,7 @@ async function push_library_dir(root_dir, options, spinner) {
399
399
  spinner.text = 'Pushing library...';
400
400
  const form_data = new FormData();
401
401
  form_data.append('file', new Blob([zip_buffer]), 'library.zip');
402
- const response = await fetch(`${server}/api/palacms/import-library`, {
402
+ const response = await fetch(`${server}/api/primo/import-library`, {
403
403
  method: 'POST',
404
404
  headers: { 'Authorization': `Bearer ${token}` },
405
405
  body: form_data
@@ -456,7 +456,7 @@ async function validate_page_types(site_dir) {
456
456
  }
457
457
  }
458
458
  // layout.yaml is required — comment-only stub is fine, but the file
459
- // must exist so the page type's shared header/footer slots are
459
+ // must exist so the page type's header/body/footer slots are
460
460
  // discoverable.
461
461
  try {
462
462
  await fs.access(layout_path);
@@ -464,7 +464,7 @@ async function validate_page_types(site_dir) {
464
464
  catch {
465
465
  errors.push({
466
466
  file: `page-types/${page_type_name}/layout.yaml`,
467
- message: 'Missing layout.yaml. Each page type needs one (use the comment-only stub if there are no shared header/footer sections yet).',
467
+ message: 'Missing layout.yaml. Each page type needs one (use the comment-only stub if there are no shared header/body/footer sections yet).',
468
468
  severity: 'error'
469
469
  });
470
470
  }
@@ -9,11 +9,10 @@ import ora from 'ora';
9
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
10
  const PRIMO_HOME = path.join(os.homedir(), '.primo');
11
11
  const BIN_DIR = path.join(PRIMO_HOME, 'bin');
12
- const DATA_DIR = path.join(PRIMO_HOME, 'data');
13
- const VERSION = '3.2.0'; // matches palacms releases
12
+ const VERSION = '3.2.1'; // matches primo releases
14
13
  // Path to locally built binary (for development)
15
- // The binary is at palacms/palacms (inside the palacms repo directory)
16
- const LOCAL_BINARY = path.resolve(__dirname, '..', '..', '..', 'palacms', 'palacms');
14
+ // The binary is at primo/primo (inside the primo repo directory)
15
+ const LOCAL_BINARY = path.resolve(__dirname, '..', '..', '..', 'primo', 'primo');
17
16
  function get_platform() {
18
17
  const platform = os.platform();
19
18
  const arch = os.arch();
@@ -48,11 +47,34 @@ function get_platform() {
48
47
  }
49
48
  function get_download_url(platform) {
50
49
  const base = 'https://github.com/primocms/primo/releases/download';
51
- const filename = `palacms_${platform.os}_${platform.arch}${platform.ext}`;
50
+ const filename = `primo_${platform.os}_${platform.arch}${platform.ext}`;
52
51
  return `${base}/v${VERSION}/${filename}`;
53
52
  }
53
+ // Extract a bare semver (e.g. "3.2.1") from a binary's --version output.
54
+ // The server prints a build banner first, then "<name> version vX.Y.Z", so we
55
+ // scan for the semver rather than trusting the whole string. Returns null when
56
+ // no semver is present (e.g. a "dev" build), which callers treat as a mismatch.
57
+ function parse_semver(output) {
58
+ if (!output)
59
+ return null;
60
+ const match = output.match(/\bv?(\d+\.\d+\.\d+)\b/);
61
+ return match ? match[1] : null;
62
+ }
54
63
  export async function get_binary_path() {
55
- // Check for locally built binary first (development)
64
+ // Explicit override wins layout-independent, the way to point at a
65
+ // local server build regardless of where this CLI lives on disk.
66
+ const override = process.env.PRIMO_BINARY;
67
+ if (override) {
68
+ try {
69
+ await fs.access(override, fs.constants.X_OK);
70
+ return override;
71
+ }
72
+ catch {
73
+ throw new Error(`PRIMO_BINARY is set to "${override}" but it is not an executable file`);
74
+ }
75
+ }
76
+ // Otherwise prefer a sibling dev build (only resolves when running from
77
+ // source next to a `primo` checkout), then fall back to the download.
56
78
  try {
57
79
  await fs.access(LOCAL_BINARY, fs.constants.X_OK);
58
80
  return LOCAL_BINARY;
@@ -60,7 +82,7 @@ export async function get_binary_path() {
60
82
  catch { }
61
83
  // Fall back to downloaded binary
62
84
  const platform = get_platform();
63
- return path.join(BIN_DIR, `palacms${platform.ext}`);
85
+ return path.join(BIN_DIR, `primo${platform.ext}`);
64
86
  }
65
87
  export async function ensure_data_dir(base_dir) {
66
88
  const data_dir = path.join(base_dir, '.primo');
@@ -78,29 +100,55 @@ export async function is_binary_installed() {
78
100
  }
79
101
  }
80
102
  export async function ensure_binary() {
81
- if (await is_binary_installed()) {
103
+ // If PRIMO_BINARY is set, honor it exclusively — surface a bad override
104
+ // rather than silently downloading a release binary behind the user's back.
105
+ if (process.env.PRIMO_BINARY) {
82
106
  return await get_binary_path();
83
107
  }
108
+ // A sibling dev build (running from source next to a `primo` checkout) is
109
+ // developer-chosen — never replace it with a download, even if its version
110
+ // differs from the pinned release.
111
+ try {
112
+ await fs.access(LOCAL_BINARY, fs.constants.X_OK);
113
+ return LOCAL_BINARY;
114
+ }
115
+ catch { }
116
+ // A managed binary already on disk is reused only when it matches the pinned
117
+ // version. A stale binary (older release, or a pre-rename "palacms" build
118
+ // reporting a different version) is re-downloaded so fixes actually reach
119
+ // users who already have a binary installed.
120
+ let updating_from = null;
121
+ if (await is_binary_installed()) {
122
+ if (await is_binary_current()) {
123
+ return await get_binary_path();
124
+ }
125
+ updating_from = await get_binary_version();
126
+ }
84
127
  // Need to download - get the target path
85
128
  const platform = get_platform();
86
- const binary_path = path.join(BIN_DIR, `palacms${platform.ext}`);
87
- const spinner = ora('Setting up Pala...').start();
129
+ const binary_path = path.join(BIN_DIR, `primo${platform.ext}`);
130
+ const spinner = ora(updating_from
131
+ ? `Updating primo ${updating_from} → ${VERSION}...`
132
+ : 'Setting up Primo...').start();
88
133
  try {
89
134
  // Create directories
90
135
  await fs.mkdir(BIN_DIR, { recursive: true });
91
136
  const url = get_download_url(platform);
92
- spinner.text = `Downloading palacms for ${platform.os}/${platform.arch}...`;
93
- // Download binary
137
+ spinner.text = `Downloading primo for ${platform.os}/${platform.arch}...`;
138
+ // Download binary. Stream to a temp path and rename into place so an
139
+ // interrupted download can't leave a half-written binary that later
140
+ // looks "installed". rename() is atomic within the same directory.
94
141
  const response = await fetch(url);
95
142
  if (!response.ok) {
96
143
  throw new Error(`Download failed: ${response.status} ${response.statusText}`);
97
144
  }
98
- // Save to file
99
- const file_stream = createWriteStream(binary_path);
145
+ const tmp_path = `${binary_path}.download`;
146
+ const file_stream = createWriteStream(tmp_path);
100
147
  await pipeline(response.body, file_stream);
101
- // Make executable
102
- await fs.chmod(binary_path, 0o755);
103
- spinner.succeed('Pala setup complete');
148
+ // Make executable, then atomically replace any existing binary.
149
+ await fs.chmod(tmp_path, 0o755);
150
+ await fs.rename(tmp_path, binary_path);
151
+ spinner.succeed(updating_from ? `Primo updated to ${VERSION}` : 'Primo setup complete');
104
152
  return binary_path;
105
153
  }
106
154
  catch (error) {
@@ -108,21 +156,33 @@ export async function ensure_binary() {
108
156
  // Provide manual instructions
109
157
  console.log('');
110
158
  console.log(chalk.yellow('To install manually:'));
111
- console.log(chalk.dim(' 1. Download palacms from https://github.com/primocms/primo/releases'));
159
+ console.log(chalk.dim(' 1. Download primo from https://github.com/primocms/primo/releases'));
112
160
  console.log(chalk.dim(` 2. Place it in ${BIN_DIR}`));
113
- console.log(chalk.dim(' 3. Make it executable: chmod +x palacms'));
161
+ console.log(chalk.dim(' 3. Make it executable: chmod +x primo'));
114
162
  console.log('');
115
163
  throw error;
116
164
  }
117
165
  }
166
+ // Return the installed binary's semver (e.g. "3.2.1"), or null if it can't be
167
+ // determined (missing binary, dev build, or unparseable output).
118
168
  export async function get_binary_version() {
119
169
  try {
120
- const binary_path = get_binary_path();
121
- const { execSync } = await import('child_process');
122
- const output = execSync(`"${binary_path}" --version`, { encoding: 'utf-8' });
123
- return output.trim();
170
+ const binary_path = await get_binary_path();
171
+ const { execFileSync } = await import('child_process');
172
+ // execFile (not execSync) avoids shell quoting issues with the path, and
173
+ // the timeout prevents a wedged binary from hanging CLI startup.
174
+ const output = execFileSync(binary_path, ['--version'], { encoding: 'utf-8', timeout: 5000 });
175
+ return parse_semver(output);
124
176
  }
125
177
  catch {
126
178
  return null;
127
179
  }
128
180
  }
181
+ // Whether the installed binary matches the version this CLI pins. A dev/unknown
182
+ // version (null) counts as not-current so we refresh to a known-good release.
183
+ // Skipped when PRIMO_BINARY or a sibling dev build is in use — those are
184
+ // developer-chosen and must not be clobbered by a download.
185
+ async function is_binary_current() {
186
+ const installed = await get_binary_version();
187
+ return installed === VERSION;
188
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {