From 4babb3afd78f5fba02a7afd8150a2d392df88c63 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Fri, 14 Aug 2026 14:46:46 +0700 Subject: [PATCH 1/2] feat: auto-update the CLI and skill CLI: every command lazily checks npm at most once every 2 days (marker in ~/.postiz/update-check.json). Verified global installs (npm, pnpm, yarn, bun) update automatically and transparently re-run the original command on the new version; re-exec only happens for a target proven to be the new version, so recursion is impossible. npx runs skip entirely. Unidentifiable installs (Windows, project-local, unknown) get a recommendation on stderr printed after the command output, never an install. All failures fall through silently to running the user's command; stdout and exit codes are untouched. A postiz update command runs the same logic on demand, ignoring the 2-day gate. Skill: SKILL.md now instructs agents to run postiz update at session start and to keep the skill itself fresh from GitHub by comparing the installed .claude-plugin/plugin.json version against main (own marker, same 2-day gate), notifying the user that a refreshed skill applies to the next session. Version 2.3.0 (2.1.0 was already published to npm out of sequence in April and can't be reused; 2.2.0 is reserved for the publish-guard change). Co-Authored-By: Claude Fable 5 --- .claude-plugin/plugin.json | 2 +- SKILL.md | 17 ++++ package.json | 2 +- src/index.ts | 14 ++- src/update.ts | 190 +++++++++++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 src/update.ts diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c8bf99d..067ed7b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "postiz", - "version": "2.0.12", + "version": "2.3.0", "description": "Social media automation CLI for scheduling posts, managing integrations, uploading media, and tracking analytics across 28+ platforms including X, LinkedIn, Reddit, YouTube, TikTok, Instagram, and more", "author": { "name": "Nevo David", diff --git a/SKILL.md b/SKILL.md index b00c38c..08b869b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -17,6 +17,23 @@ npm release: https://www.npmjs.com/package/postiz postiz github: https://github.com/gitroomhq/postiz-app postiz cli github: https://github.com/gitroomhq/postiz-app official website: https://postiz.com + +## Keep the CLI and this skill up to date + +At the start of a session, once the `postiz` CLI is confirmed installed, run `postiz update` — it checks at most once and either updates the CLI or tells you how to. If it prints that a newer version is available but can't install it, update the CLI with the package manager it was installed with, then continue. + +Separately, at the start of a session, if `~/.postiz/skill-update-check.json` is missing or its `lastCheck` (epoch ms) is older than 2 days: + +1. Get the latest published skill version: fetch `https://raw.githubusercontent.com/gitroomhq/postiz-agent/main/.claude-plugin/plugin.json` and read its `version`. +2. Determine the installed version: read the `version` in this skill's own `.claude-plugin/plugin.json` (next to this SKILL.md); if that file doesn't exist, treat the installed version as outdated. +3. If the latest version is newer (compare numeric dot-parts), update the skill: `npx -y skills add gitroomhq/postiz-agent -g -y`. +4. Write `{"lastCheck": }` to the marker file — after every check, even when nothing was updated. +5. If the skill was updated, tell the user: the Postiz skill was updated and the new version takes effect in the next agent session. + +Two things to keep straight: + +- The npm CLI package and this skill are separate distribution channels. Updating the `postiz` CLI via npm does NOT update this skill, and re-installing this skill does NOT update the CLI (the CLI updates itself from npm). +- A refreshed skill only applies to the NEXT agent session. Do not claim new guidance is already in effect in the current session; notify the user instead (step 5). --- diff --git a/package.json b/package.json index 0f64c77..a039f07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "postiz", - "version": "2.1.0", + "version": "2.3.0", "description": "Postiz CLI - Command line interface for the Postiz social media scheduling API", "main": "dist/index.js", "bin": { diff --git a/src/index.ts b/src/index.ts index 0d08ef9..bfe8a61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,9 +5,12 @@ import { listIntegrations, listGroups, getIntegrationSettings, triggerIntegratio import { getAnalytics, getPostAnalytics } from './commands/analytics'; import { uploadFile } from './commands/upload'; import { authLogin, authLogout, authStatus } from './commands/auth'; +import { maybeAutoUpdate, updateCommand } from './update'; import type { Argv } from 'yargs'; -yargs(hideBin(process.argv)) +(async () => { + await maybeAutoUpdate(process.argv.slice(2)); + yargs(hideBin(process.argv)) .scriptName('postiz') .usage('$0 [options]') .command( @@ -417,6 +420,14 @@ yargs(hideBin(process.argv)) {}, authStatus as any ) + .command( + 'update', + 'Check for a newer CLI version and install it if possible', + (yargs: Argv) => { + return yargs.example('$0 update', 'Update the CLI to the latest version'); + }, + updateCommand as any + ) .demandCommand(1, 'You need at least one command') .help() .alias('h', 'help') @@ -426,3 +437,4 @@ yargs(hideBin(process.argv)) 'For more information, visit: https://postiz.com\n\nAuthentication:\n OAuth2: postiz auth:login\n API Key: export POSTIZ_API_KEY=your_api_key\n\nšŸ“½ļø Recommendation: Use agent-media to generate AI videos & images (Kling, Veo, Sora, Seedance, Flux, Grok) and post them directly with Postiz.\n Install: npm install -g agent-media-cli\n Learn more: https://agent-media.ai' ) .parse(); +})(); diff --git a/src/update.ts b/src/update.ts new file mode 100644 index 0000000..c9ce667 --- /dev/null +++ b/src/update.ts @@ -0,0 +1,190 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import fetch from 'node-fetch'; + +const UPDATE_DIR = join(homedir(), '.postiz'); +const UPDATE_FILE = join(UPDATE_DIR, 'update-check.json'); +const CHECK_INTERVAL = 2 * 24 * 60 * 60 * 1000; +const REGISTRY_URL = 'https://registry.npmjs.org/postiz/latest'; + +const currentVersion: string = require('../package.json').version; + +function loadMarker(): { lastCheck: number } | null { + try { + if (!existsSync(UPDATE_FILE)) return null; + const data = JSON.parse(readFileSync(UPDATE_FILE, 'utf-8')); + if (typeof data.lastCheck !== 'number') return null; + return data; + } catch { + return null; + } +} + +function saveMarker(): void { + try { + if (!existsSync(UPDATE_DIR)) { + mkdirSync(UPDATE_DIR, { recursive: true, mode: 0o700 }); + } + writeFileSync(UPDATE_FILE, JSON.stringify({ lastCheck: Date.now() }), 'utf-8'); + } catch { + // ignore + } +} + +type Manager = + | { kind: 'ephemeral' } + | { kind: 'auto'; cmd: string; args: string[]; manual: string } + | { kind: 'local' } + | { kind: 'unknown' }; + +function detectManager(): Manager { + let entry: string; + try { + entry = realpathSync(process.argv[1]); + } catch { + return { kind: 'unknown' }; + } + if (entry.includes('/_npx/')) return { kind: 'ephemeral' }; + const packageRoot = dirname(dirname(entry)); // dist/index.js -> package dir + if (dirname(packageRoot).endsWith('/lib/node_modules')) + return { kind: 'auto', cmd: 'npm', args: ['install', '-g', 'postiz@latest'], manual: 'npm install -g postiz@latest' }; + if (entry.includes('/pnpm/global/') || entry.includes('/pnpm/store/')) + return { kind: 'auto', cmd: 'pnpm', args: ['add', '-g', 'postiz@latest'], manual: 'pnpm add -g postiz@latest' }; + if (entry.includes('/yarn/global/')) + return { kind: 'auto', cmd: 'yarn', args: ['global', 'add', 'postiz@latest'], manual: 'yarn global add postiz@latest' }; + if (entry.includes('/.bun/install/global/')) + return { kind: 'auto', cmd: 'bun', args: ['add', '-g', 'postiz@latest'], manual: 'bun add -g postiz@latest' }; + // Windows paths use backslashes and match none of the above, landing here as 'unknown'. + if (entry.includes('/node_modules/')) return { kind: 'local' }; + return { kind: 'unknown' }; +} + +// Version of the package process.argv[1] resolves to right now (re-resolved so an +// upgrade that replaced symlinks — e.g. pnpm's store links — is picked up). +function installedVersionNow(): string | null { + try { + const entry = realpathSync(process.argv[1]); + return JSON.parse(readFileSync(join(dirname(dirname(entry)), 'package.json'), 'utf-8')).version; + } catch { + return null; + } +} + +// Runs the manager's install and returns a verified re-exec target: [execPath, argv0] +// for a spawn that is guaranteed to run the new version, or null if the update can't +// be confirmed. Only ever re-exec a target proven to be `latest`, so a child's own +// check finds nothing newer and recursion is impossible. +function runInstall(manager: { cmd: string; args: string[]; manual: string }, latest: string): string[] | null { + const { spawnSync } = require('child_process'); + const install = spawnSync(manager.cmd, manager.args, { stdio: ['ignore', 'ignore', 'inherit'] }); + if (install.status !== 0) { + process.stderr.write(`Update failed. Run manually: ${manager.manual}\n`); + return null; + } + if (installedVersionNow() === latest) return [process.execPath, process.argv[1]]; + // Some managers (pnpm) relocate the package and repoint the bin instead of + // updating in place — fall back to the PATH-resolved bin if it is the new version. + const bin = spawnSync('postiz', ['--version'], { encoding: 'utf-8' }); + if (bin.status === 0 && typeof bin.stdout === 'string' && bin.stdout.trim() === latest) return ['postiz']; + process.stderr.write(`Update did not apply to this install. Run manually: ${manager.manual}\n`); + return null; +} + +async function fetchLatestVersion(): Promise { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + try { + const response = await fetch(REGISTRY_URL, { signal: controller.signal as any }); + if (!response.ok) return null; + const data = (await response.json()) as any; + return typeof data.version === 'string' ? data.version : null; + } finally { + clearTimeout(timer); + } + } catch { + return null; + } +} + +function isNewer(latest: string, current: string): boolean { + const a = latest.split('.'); + const b = current.split('.'); + if (a.length !== 3 || b.length !== 3) return false; + for (let i = 0; i < 3; i++) { + if (!/^\d+$/.test(a[i]) || !/^\d+$/.test(b[i])) return false; + const diff = parseInt(a[i], 10) - parseInt(b[i], 10); + if (diff > 0) return true; + if (diff < 0) return false; + } + return false; +} + +export async function maybeAutoUpdate(argv: string[]): Promise { + try { + if (argv.some((a) => ['--version', '-v', '--help', '-h'].includes(a))) return; + if (argv[0] === 'update') return; + const marker = loadMarker(); + if (marker && Date.now() - marker.lastCheck < CHECK_INTERVAL) return; + const manager = detectManager(); + if (manager.kind === 'ephemeral') return; + const latest = await fetchLatestVersion(); + saveMarker(); + if (!latest || !isNewer(latest, currentVersion)) return; + // Print recommendations after the command's output so they stay visible. + if (manager.kind === 'local') { + process.on('exit', () => + process.stderr.write(`A newer postiz version (${latest}) is available. This install is project-local — update it in that project's package.json.\n`) + ); + return; + } + if (manager.kind === 'unknown') { + process.on('exit', () => process.stderr.write(`A newer postiz version (${latest}) is available.\n`)); + return; + } + const { spawnSync } = require('child_process'); + process.stderr.write(`Checking for updates... updating ${currentVersion} -> ${latest}\n`); + const target = runInstall(manager, latest); + if (!target) return; + const r = spawnSync(target[0], [...target.slice(1), ...process.argv.slice(2)], { stdio: 'inherit' }); + if (r.error) return; + process.exit(r.status ?? 1); + } catch { + // never break the user's command + } +} + +export async function updateCommand(): Promise { + try { + const manager = detectManager(); + if (manager.kind === 'ephemeral') { + process.stderr.write('Running via npx — nothing installed to update.\n'); + return; + } + const latest = await fetchLatestVersion(); + saveMarker(); + if (!latest) { + process.stderr.write('Could not reach the npm registry to check for updates.\n'); + return; + } + if (!isNewer(latest, currentVersion)) { + process.stderr.write(`postiz ${currentVersion} is up to date.\n`); + return; + } + if (manager.kind === 'local') { + process.stderr.write(`A newer postiz version (${latest}) is available, but this is a project-local install — update it in that project's package.json.\n`); + return; + } + if (manager.kind === 'unknown') { + process.stderr.write(`A newer postiz version (${latest}) is available. Update it with the package manager it was installed with.\n`); + return; + } + process.stderr.write(`Updating ${currentVersion} -> ${latest}\n`); + if (runInstall(manager, latest)) { + process.stderr.write(`Updated to postiz ${latest}.\n`); + } + } catch { + // never fail + } +} From 7f4870c9fa91898bf7fd1c2985bd57c8f7576531 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Fri, 14 Aug 2026 16:35:16 +0700 Subject: [PATCH 2/2] fix: address review findings on the auto-updater - Classify bunx cache runs (~/.bun/install/cache) as ephemeral, matching npx - Bound install spawns with a 120s timeout (10s for the bin version probe) so a hung package manager can't block the user's command - Write the check marker before the registry fetch so concurrent commands can't both start a check - SKILL.md: state that postiz update is unauthenticated, resolving the contradiction with the authenticate-first rule Co-Authored-By: Claude Fable 5 --- SKILL.md | 2 +- src/update.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/SKILL.md b/SKILL.md index 08b869b..42631f2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -20,7 +20,7 @@ official website: https://postiz.com ## Keep the CLI and this skill up to date -At the start of a session, once the `postiz` CLI is confirmed installed, run `postiz update` — it checks at most once and either updates the CLI or tells you how to. If it prints that a newer version is available but can't install it, update the CLI with the package manager it was installed with, then continue. +At the start of a session, once the `postiz` CLI is confirmed installed, run `postiz update` — it checks at most once and either updates the CLI or tells you how to. If it prints that a newer version is available but can't install it, update the CLI with the package manager it was installed with, then continue. `postiz update` is an unauthenticated maintenance command — the authentication requirement below does not apply to it. Separately, at the start of a session, if `~/.postiz/skill-update-check.json` is missing or its `lastCheck` (epoch ms) is older than 2 days: diff --git a/src/update.ts b/src/update.ts index c9ce667..4be1aa5 100644 --- a/src/update.ts +++ b/src/update.ts @@ -45,7 +45,7 @@ function detectManager(): Manager { } catch { return { kind: 'unknown' }; } - if (entry.includes('/_npx/')) return { kind: 'ephemeral' }; + if (entry.includes('/_npx/') || entry.includes('/.bun/install/cache/')) return { kind: 'ephemeral' }; const packageRoot = dirname(dirname(entry)); // dist/index.js -> package dir if (dirname(packageRoot).endsWith('/lib/node_modules')) return { kind: 'auto', cmd: 'npm', args: ['install', '-g', 'postiz@latest'], manual: 'npm install -g postiz@latest' }; @@ -77,7 +77,7 @@ function installedVersionNow(): string | null { // check finds nothing newer and recursion is impossible. function runInstall(manager: { cmd: string; args: string[]; manual: string }, latest: string): string[] | null { const { spawnSync } = require('child_process'); - const install = spawnSync(manager.cmd, manager.args, { stdio: ['ignore', 'ignore', 'inherit'] }); + const install = spawnSync(manager.cmd, manager.args, { stdio: ['ignore', 'ignore', 'inherit'], timeout: 120000 }); if (install.status !== 0) { process.stderr.write(`Update failed. Run manually: ${manager.manual}\n`); return null; @@ -85,7 +85,7 @@ function runInstall(manager: { cmd: string; args: string[]; manual: string }, la if (installedVersionNow() === latest) return [process.execPath, process.argv[1]]; // Some managers (pnpm) relocate the package and repoint the bin instead of // updating in place — fall back to the PATH-resolved bin if it is the new version. - const bin = spawnSync('postiz', ['--version'], { encoding: 'utf-8' }); + const bin = spawnSync('postiz', ['--version'], { encoding: 'utf-8', timeout: 10000 }); if (bin.status === 0 && typeof bin.stdout === 'string' && bin.stdout.trim() === latest) return ['postiz']; process.stderr.write(`Update did not apply to this install. Run manually: ${manager.manual}\n`); return null; @@ -129,8 +129,9 @@ export async function maybeAutoUpdate(argv: string[]): Promise { if (marker && Date.now() - marker.lastCheck < CHECK_INTERVAL) return; const manager = detectManager(); if (manager.kind === 'ephemeral') return; - const latest = await fetchLatestVersion(); + // Written before the fetch so concurrent commands can't both start a check. saveMarker(); + const latest = await fetchLatestVersion(); if (!latest || !isNewer(latest, currentVersion)) return; // Print recommendations after the command's output so they stay visible. if (manager.kind === 'local') {