openpalm 0.9.9 → 0.9.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpalm",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
6
  "description": "OpenPalm CLI — install and manage a self-hosted OpenPalm stack",
@@ -26,7 +26,7 @@
26
26
  "build:windows-arm64": "bun build src/main.ts --compile --target=bun-windows-arm64 --outfile dist/openpalm-cli-windows-arm64.exe"
27
27
  },
28
28
  "dependencies": {
29
- "@openpalm/lib": "0.9.6",
29
+ "@openpalm/lib": ">=0.9.8 <1.0.0",
30
30
  "citty": "^0.2.1"
31
31
  }
32
32
  }
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { rm } from 'node:fs/promises';
4
4
  import cliPkg from '../../package.json' with { type: 'json' };
5
5
  import { defaultConfigHome, defaultDataHome, defaultStateHome, defaultWorkDir } from '../lib/paths.ts';
6
- import { ensureSecrets, ensureStackEnv } from '../lib/env.ts';
6
+ import { ensureSecrets, ensureStackEnv, resolveRequestedImageTag } from '../lib/env.ts';
7
7
  import { ensureDirectoryTree, fetchAsset, runDockerCompose, openBrowser } from '../lib/docker.ts';
8
8
  import {
9
9
  ensureOpenCodeConfig, ensureOpenCodeSystemConfig, ensureAdminOpenCodeConfig, FilesystemAssetProvider,
@@ -16,9 +16,31 @@ import { ensureStagedState, fullComposeArgs, buildManagedServiceNames } from '..
16
16
  import { createSetupServer } from '../setup-wizard/server.ts';
17
17
  import { buildInstallServiceNames, buildDeployStatusEntries } from './install-services.ts';
18
18
 
19
- const DEFAULT_INSTALL_REF = 'main';
20
19
  const SETUP_WIZARD_PORT = Number(process.env.OPENPALM_SETUP_PORT) || 8100;
21
20
 
21
+ const REPO_OWNER = 'itlackey';
22
+ const REPO_NAME = 'openpalm';
23
+
24
+ /**
25
+ * Resolves the latest release tag from GitHub. Falls back to the CLI package
26
+ * version (prefixed with 'v') so the install never silently defaults to 'main'
27
+ * which produces an un-pinned 'latest' image tag.
28
+ */
29
+ async function resolveDefaultInstallRef(): Promise<string> {
30
+ try {
31
+ const res = await fetch(
32
+ `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
33
+ { redirect: 'manual', signal: AbortSignal.timeout(10000) },
34
+ );
35
+ const location = res.headers.get('location') ?? '';
36
+ const match = location.match(/\/tag\/(v[0-9]+\.[0-9]+\.[0-9]+[^\s]*)$/);
37
+ if (match?.[1]) return match[1];
38
+ } catch {
39
+ // Network error — fall through to package version
40
+ }
41
+ return cliPkg.version ? `v${cliPkg.version}` : 'main';
42
+ }
43
+
22
44
  export default defineCommand({
23
45
  meta: {
24
46
  name: 'install',
@@ -32,8 +54,7 @@ export default defineCommand({
32
54
  },
33
55
  version: {
34
56
  type: 'string',
35
- description: 'Install specific repository ref (default: main)',
36
- default: DEFAULT_INSTALL_REF,
57
+ description: 'Install specific repository ref (default: latest release)',
37
58
  },
38
59
  start: {
39
60
  type: 'boolean',
@@ -52,9 +73,10 @@ export default defineCommand({
52
73
  },
53
74
  },
54
75
  async run({ args }) {
76
+ const version = args.version || await resolveDefaultInstallRef();
55
77
  await bootstrapInstall({
56
78
  force: args.force,
57
- version: args.version,
79
+ version,
58
80
  noStart: !args.start,
59
81
  noOpen: !args.open,
60
82
  file: args.file,
@@ -147,7 +169,10 @@ export async function bootstrapInstall(options: InstallOptions): Promise<void> {
147
169
  );
148
170
 
149
171
  await ensureSecrets(configHome);
150
- await ensureStackEnv(configHome, dataHome, stateHome, workDir, options.version);
172
+ // Derive the image tag from the resolved version so that stale or
173
+ // architecture-suffixed OPENPALM_IMAGE_TAG env vars don't leak in.
174
+ const imageTag = resolveRequestedImageTag(options.version) ?? undefined;
175
+ await ensureStackEnv(configHome, dataHome, stateHome, workDir, options.version, imageTag);
151
176
  // Seed OpenCode config — non-fatal since performSetup() also seeds these
152
177
  try {
153
178
  const fsAssets = new FilesystemAssetProvider(dataHome);
package/src/lib/env.ts CHANGED
@@ -96,6 +96,11 @@ export MEMORY_AUTH_TOKEN=${randomBytes(32).toString('hex')}
96
96
 
97
97
  /**
98
98
  * Creates or updates the stack.env bootstrap file.
99
+ *
100
+ * When `imageTagOverride` is provided (e.g. derived from --version during
101
+ * install), it takes precedence over both the OPENPALM_IMAGE_TAG env var
102
+ * and the repo-ref heuristic. This prevents stale or architecture-suffixed
103
+ * env vars (e.g. "latest-arm64") from leaking into the stack.
99
104
  */
100
105
  export async function ensureStackEnv(
101
106
  configHome: string,
@@ -103,10 +108,11 @@ export async function ensureStackEnv(
103
108
  stateHome: string,
104
109
  workDir: string,
105
110
  repoRef: string,
111
+ imageTagOverride?: string,
106
112
  ): Promise<void> {
107
113
  const dataStackEnv = join(dataHome, 'stack.env');
108
114
  const stagedStackEnv = join(stateHome, 'artifacts', 'stack.env');
109
- const explicitImageTag = process.env.OPENPALM_IMAGE_TAG;
115
+ const explicitImageTag = imageTagOverride ?? process.env.OPENPALM_IMAGE_TAG;
110
116
  const hasExplicitImageTag = explicitImageTag !== undefined && explicitImageTag !== '';
111
117
  if (!(await Bun.file(dataStackEnv).exists())) {
112
118
  const defaultImageTag = hasExplicitImageTag
package/src/main.test.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { afterEach, describe, expect, it, mock } from 'bun:test';
2
- import { existsSync, mkdirSync, writeFileSync, chmodSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, writeFileSync, chmodSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { detectHostInfo, main, reconcileStackEnvImageTag, resolveRequestedImageTag, upsertEnvValue } from './main.ts';
6
7
 
7
8
  // Helpers to mock Bun.spawn and Bun.which for tests that would otherwise
@@ -151,7 +152,7 @@ describe('cli main', () => {
151
152
  }
152
153
  });
153
154
 
154
- it('uses main as the default install ref for bootstrap asset downloads', async () => {
155
+ it('resolves version-pinned install ref (falls back to CLI package version)', async () => {
155
156
  const base = mkdtempSync(join(tmpdir(), 'openpalm-install-'));
156
157
  const configHome = join(base, 'config');
157
158
  const dataHome = join(base, 'data');
@@ -169,6 +170,12 @@ describe('cli main', () => {
169
170
  process.env.OPENPALM_STATE_HOME = stateHome;
170
171
  process.env.OPENPALM_WORK_DIR = workDir;
171
172
 
173
+ // Read the CLI package version to verify pinning behaviour
174
+ const cliPkg = JSON.parse(
175
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
176
+ ) as { version: string };
177
+ const expectedRef = `v${cliPkg.version}`;
178
+
172
179
  mockDockerCli();
173
180
  globalThis.fetch = mock(async (input: string | URL) => {
174
181
  const url = String(input);
@@ -176,13 +183,14 @@ describe('cli main', () => {
176
183
  if (url.endsWith('/health')) {
177
184
  throw new TypeError('fetch failed');
178
185
  }
179
- if (url.includes('/main/assets/docker-compose.yml')) {
186
+ // Respond to version-pinned asset URLs
187
+ if (url.includes('/docker-compose.yml')) {
180
188
  return new Response('services: {}\n', { status: 200 });
181
189
  }
182
- if (url.includes('/main/assets/Caddyfile')) {
190
+ if (url.includes('/Caddyfile')) {
183
191
  return new Response(':80 {\n}\n', { status: 200 });
184
192
  }
185
- if (url.includes('/main/assets/secrets.env.schema') || url.includes('/main/assets/stack.env.schema')) {
193
+ if (url.includes('/secrets.env.schema') || url.includes('/stack.env.schema')) {
186
194
  return new Response('KEY=string\n', { status: 200 });
187
195
  }
188
196
  return new Response('', { status: 503 });
@@ -193,12 +201,15 @@ describe('cli main', () => {
193
201
  try {
194
202
  await main(['install', '--no-start', '--force', '--no-open']);
195
203
 
196
- expect(fetchedUrls).toContain(
197
- 'https://raw.githubusercontent.com/itlackey/openpalm/main/assets/docker-compose.yml',
198
- );
199
- expect(fetchedUrls).toContain(
200
- 'https://raw.githubusercontent.com/itlackey/openpalm/main/assets/Caddyfile',
201
- );
204
+ // Verify that assets were fetched using the version-pinned ref, not 'main'
205
+ const composeUrl = fetchedUrls.find((u) => u.includes('/docker-compose.yml'));
206
+ expect(composeUrl).toBeDefined();
207
+ expect(composeUrl).toContain(expectedRef);
208
+ expect(composeUrl).not.toContain('/main/');
209
+
210
+ const caddyUrl = fetchedUrls.find((u) => u.includes('/Caddyfile'));
211
+ expect(caddyUrl).toBeDefined();
212
+ expect(caddyUrl).toContain(expectedRef);
202
213
  } finally {
203
214
  rmSync(base, { recursive: true, force: true });
204
215
  }
@@ -219,6 +230,65 @@ describe('npm bin launcher', () => {
219
230
 
220
231
  expect(launcher.startsWith('#!/usr/bin/env bun\n')).toBe(true);
221
232
  });
233
+
234
+ it('packs a real semver range for @openpalm/lib so published installs can resolve the latest compatible lib', {
235
+ timeout: 15000,
236
+ }, () => {
237
+ const cliPkg = JSON.parse(
238
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
239
+ ) as {
240
+ dependencies?: Record<string, string>;
241
+ };
242
+ const libPkg = JSON.parse(
243
+ readFileSync(new URL('../../lib/package.json', import.meta.url), 'utf8'),
244
+ ) as {
245
+ version: string;
246
+ };
247
+ const versionMatch = libPkg.version.match(/^(\d+)\.\d+\.\d+(?:-.+)?$/);
248
+ if (!versionMatch) throw new Error(`Unexpected lib version format: ${libPkg.version}`);
249
+ const libMajor = Number.parseInt(versionMatch[1], 10);
250
+
251
+ const expectedRange = `>=${libPkg.version} <${libMajor + 1}.0.0`;
252
+
253
+ expect(cliPkg.dependencies?.['@openpalm/lib']).toBe(expectedRange);
254
+
255
+ const packageDir = fileURLToPath(new URL('../', import.meta.url));
256
+ const packDir = mkdtempSync(join(tmpdir(), 'openpalm-cli-pack-'));
257
+
258
+ try {
259
+ const pack = Bun.spawnSync(
260
+ [process.execPath, 'pm', 'pack', '--destination', packDir, '--quiet'],
261
+ {
262
+ cwd: packageDir,
263
+ stdout: 'pipe',
264
+ stderr: 'pipe',
265
+ },
266
+ );
267
+
268
+ expect(pack.exitCode).toBe(0);
269
+
270
+ const tarball = readdirSync(packDir).find((name) => name.endsWith('.tgz'));
271
+ if (!tarball) throw new Error('Expected bun pm pack to produce a tarball');
272
+
273
+ const extract = Bun.spawnSync(
274
+ ['tar', '-xOf', join(packDir, tarball), 'package/package.json'],
275
+ {
276
+ stdout: 'pipe',
277
+ stderr: 'pipe',
278
+ },
279
+ );
280
+
281
+ expect(extract.exitCode).toBe(0);
282
+
283
+ const packedPkg = JSON.parse(new TextDecoder().decode(extract.stdout)) as {
284
+ dependencies?: Record<string, string>;
285
+ };
286
+
287
+ expect(packedPkg.dependencies?.['@openpalm/lib']).toBe(expectedRange);
288
+ } finally {
289
+ rmSync(packDir, { recursive: true, force: true });
290
+ }
291
+ });
222
292
  });
223
293
 
224
294
  describe('validate command', () => {