feat: install.sh + auto-update checker for CLI
- tools/install.sh: standalone installer/upgrader, curl-pipe safe (main() wrapper, process.argv instead of stdin, mkdir -p prefix) - packages/mosaic/src/runtime/update-checker.ts: version check module with 1h cache at ~/.cache/mosaic/update-check.json - CLI startup: non-blocking background update check on every invocation - 'mosaic update' command: explicit check + install (--check for CI) - session-start.sh: warns agents when CLI is outdated - Proper semver comparison including pre-release precedence - eslint: allow __tests__ in packages/mosaic for projectService
This commit is contained in:
@@ -27,6 +27,7 @@ export default tseslint.config(
|
|||||||
'apps/web/e2e/*.ts',
|
'apps/web/e2e/*.ts',
|
||||||
'apps/web/e2e/helpers/*.ts',
|
'apps/web/e2e/helpers/*.ts',
|
||||||
'apps/web/playwright.config.ts',
|
'apps/web/playwright.config.ts',
|
||||||
|
'packages/mosaic/__tests__/*.ts',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import { registerPrdyCommand } from './commands/prdy.js';
|
|||||||
const _require = createRequire(import.meta.url);
|
const _require = createRequire(import.meta.url);
|
||||||
const CLI_VERSION: string = (_require('../package.json') as { version: string }).version;
|
const CLI_VERSION: string = (_require('../package.json') as { version: string }).version;
|
||||||
|
|
||||||
|
// Fire-and-forget update check at startup (non-blocking, cached 1h)
|
||||||
|
try {
|
||||||
|
const { backgroundUpdateCheck } = await import('@mosaic/mosaic');
|
||||||
|
backgroundUpdateCheck();
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — update check is best-effort
|
||||||
|
}
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION);
|
program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION);
|
||||||
@@ -297,6 +305,52 @@ if (qrCmd !== undefined) {
|
|||||||
program.addCommand(qrCmd as unknown as Command);
|
program.addCommand(qrCmd as unknown as Command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── update ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('update')
|
||||||
|
.description('Check for and install Mosaic CLI updates')
|
||||||
|
.option('--check', 'Check only, do not install')
|
||||||
|
.action(async (opts: { check?: boolean }) => {
|
||||||
|
const { checkForUpdate, formatUpdateNotice } = await import('@mosaic/mosaic');
|
||||||
|
const { execSync } = await import('node:child_process');
|
||||||
|
|
||||||
|
console.log('Checking for updates…');
|
||||||
|
const result = checkForUpdate({ skipCache: true });
|
||||||
|
|
||||||
|
if (!result.latest) {
|
||||||
|
console.error('Could not reach the Mosaic registry.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Installed: ${result.current || '(none)'}`);
|
||||||
|
console.log(` Latest: ${result.latest}`);
|
||||||
|
|
||||||
|
if (!result.updateAvailable) {
|
||||||
|
console.log('\n✔ Up to date.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notice = formatUpdateNotice(result);
|
||||||
|
if (notice) console.log(notice);
|
||||||
|
|
||||||
|
if (opts.check) {
|
||||||
|
process.exit(2); // Signal to callers that an update exists
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Installing update…');
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
'npm install -g @mosaic/cli@latest --registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/',
|
||||||
|
{ stdio: 'inherit', timeout: 60_000 },
|
||||||
|
);
|
||||||
|
console.log('\n✔ Updated successfully.');
|
||||||
|
} catch {
|
||||||
|
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── wizard ─────────────────────────────────────────────────────────────
|
// ─── wizard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|||||||
52
packages/mosaic/__tests__/update-checker.test.ts
Normal file
52
packages/mosaic/__tests__/update-checker.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { semverLt, formatUpdateNotice } from '../src/runtime/update-checker.js';
|
||||||
|
import type { UpdateCheckResult } from '../src/runtime/update-checker.js';
|
||||||
|
|
||||||
|
describe('semverLt', () => {
|
||||||
|
it('returns true when a < b', () => {
|
||||||
|
expect(semverLt('0.0.1', '0.0.2')).toBe(true);
|
||||||
|
expect(semverLt('0.1.0', '0.2.0')).toBe(true);
|
||||||
|
expect(semverLt('1.0.0', '2.0.0')).toBe(true);
|
||||||
|
expect(semverLt('0.0.1-alpha.1', '0.0.1-alpha.2')).toBe(true);
|
||||||
|
expect(semverLt('0.0.1-alpha.1', '0.0.1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when a >= b', () => {
|
||||||
|
expect(semverLt('0.0.2', '0.0.1')).toBe(false);
|
||||||
|
expect(semverLt('1.0.0', '1.0.0')).toBe(false);
|
||||||
|
expect(semverLt('2.0.0', '1.0.0')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for empty strings', () => {
|
||||||
|
expect(semverLt('', '1.0.0')).toBe(false);
|
||||||
|
expect(semverLt('1.0.0', '')).toBe(false);
|
||||||
|
expect(semverLt('', '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatUpdateNotice', () => {
|
||||||
|
it('returns empty string when up to date', () => {
|
||||||
|
const result: UpdateCheckResult = {
|
||||||
|
current: '1.0.0',
|
||||||
|
latest: '1.0.0',
|
||||||
|
updateAvailable: false,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
registry: 'https://example.com',
|
||||||
|
};
|
||||||
|
expect(formatUpdateNotice(result)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a notice when update is available', () => {
|
||||||
|
const result: UpdateCheckResult = {
|
||||||
|
current: '0.0.1',
|
||||||
|
latest: '0.1.0',
|
||||||
|
updateAvailable: true,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
registry: 'https://example.com',
|
||||||
|
};
|
||||||
|
const notice = formatUpdateNotice(result);
|
||||||
|
expect(notice).toContain('0.0.1');
|
||||||
|
expect(notice).toContain('0.1.0');
|
||||||
|
expect(notice).toContain('Update available');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,15 @@ import type { CommunicationStyle } from './types.js';
|
|||||||
|
|
||||||
export { VERSION, DEFAULT_MOSAIC_HOME };
|
export { VERSION, DEFAULT_MOSAIC_HOME };
|
||||||
export { runWizard } from './wizard.js';
|
export { runWizard } from './wizard.js';
|
||||||
|
export {
|
||||||
|
checkForUpdate,
|
||||||
|
backgroundUpdateCheck,
|
||||||
|
formatUpdateNotice,
|
||||||
|
getInstalledVersion,
|
||||||
|
getLatestVersion,
|
||||||
|
semverLt,
|
||||||
|
} from './runtime/update-checker.js';
|
||||||
|
export type { UpdateCheckResult } from './runtime/update-checker.js';
|
||||||
export { ClackPrompter } from './prompter/clack-prompter.js';
|
export { ClackPrompter } from './prompter/clack-prompter.js';
|
||||||
export { HeadlessPrompter } from './prompter/headless-prompter.js';
|
export { HeadlessPrompter } from './prompter/headless-prompter.js';
|
||||||
export { createConfigService } from './config/config-service.js';
|
export { createConfigService } from './config/config-service.js';
|
||||||
|
|||||||
238
packages/mosaic/src/runtime/update-checker.ts
Normal file
238
packages/mosaic/src/runtime/update-checker.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Mosaic update checker — compares installed @mosaic/cli version against the
|
||||||
|
* Gitea npm registry and reports when an upgrade is available.
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* - CLI startup (non-blocking background check)
|
||||||
|
* - session-start.sh (via `mosaic update --check`)
|
||||||
|
* - install.sh (direct invocation)
|
||||||
|
*
|
||||||
|
* Design:
|
||||||
|
* - Result is cached to ~/.cache/mosaic/update-check.json for 1 hour
|
||||||
|
* - Network call is fire-and-forget with a 5 s timeout
|
||||||
|
* - Never throws on failure — returns stale/unknown result
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface UpdateCheckResult {
|
||||||
|
/** Currently installed version (empty if not found) */
|
||||||
|
current: string;
|
||||||
|
/** Latest published version (empty if check failed) */
|
||||||
|
latest: string;
|
||||||
|
/** True when a newer version is available */
|
||||||
|
updateAvailable: boolean;
|
||||||
|
/** ISO timestamp of this check */
|
||||||
|
checkedAt: string;
|
||||||
|
/** Where the latest version was resolved from */
|
||||||
|
registry: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
|
||||||
|
const CLI_PKG = '@mosaic/cli';
|
||||||
|
const CACHE_DIR = join(homedir(), '.cache', 'mosaic');
|
||||||
|
const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
|
||||||
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
const NETWORK_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
|
||||||
|
try {
|
||||||
|
return execSync(`npm ${args}`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: timeoutMs,
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
}).trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rudimentary semver "a < b" — handles pre-release tags.
|
||||||
|
* Per semver spec: 1.0.0-alpha.1 < 1.0.0 (pre-release has lower precedence).
|
||||||
|
*/
|
||||||
|
export function semverLt(a: string, b: string): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
|
||||||
|
const stripped = (v: string) => v.replace(/^v/, '');
|
||||||
|
const splitPre = (v: string): [string, string | null] => {
|
||||||
|
const idx = v.indexOf('-');
|
||||||
|
return idx === -1 ? [v, null] : [v.slice(0, idx), v.slice(idx + 1)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const sa = stripped(a);
|
||||||
|
const sb = stripped(b);
|
||||||
|
const [coreA, preA] = splitPre(sa);
|
||||||
|
const [coreB, preB] = splitPre(sb);
|
||||||
|
|
||||||
|
// Compare core version (major.minor.patch)
|
||||||
|
const partsA = coreA.split('.').map(Number);
|
||||||
|
const partsB = coreB.split('.').map(Number);
|
||||||
|
const len = Math.max(partsA.length, partsB.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const va = partsA[i] ?? 0;
|
||||||
|
const vb = partsB[i] ?? 0;
|
||||||
|
if (va < vb) return true;
|
||||||
|
if (va > vb) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core versions are equal — compare pre-release
|
||||||
|
// No pre-release > any pre-release (1.0.0 > 1.0.0-alpha)
|
||||||
|
if (preA !== null && preB === null) return true;
|
||||||
|
if (preA === null && preB !== null) return false;
|
||||||
|
if (preA === null && preB === null) return false;
|
||||||
|
|
||||||
|
// Both have pre-release — compare dot-separated identifiers
|
||||||
|
const idsA = preA!.split('.');
|
||||||
|
const idsB = preB!.split('.');
|
||||||
|
const preLen = Math.max(idsA.length, idsB.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < preLen; i++) {
|
||||||
|
const ia = idsA[i];
|
||||||
|
const ib = idsB[i];
|
||||||
|
// Fewer fields = lower precedence
|
||||||
|
if (ia === undefined && ib !== undefined) return true;
|
||||||
|
if (ia !== undefined && ib === undefined) return false;
|
||||||
|
const na = /^\d+$/.test(ia!) ? parseInt(ia!, 10) : null;
|
||||||
|
const nb = /^\d+$/.test(ib!) ? parseInt(ib!, 10) : null;
|
||||||
|
// Numeric vs string: numeric < string
|
||||||
|
if (na !== null && nb !== null) {
|
||||||
|
if (na < nb) return true;
|
||||||
|
if (na > nb) return false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (na !== null && nb === null) return true;
|
||||||
|
if (na === null && nb !== null) return false;
|
||||||
|
// Both strings
|
||||||
|
if (ia! < ib!) return true;
|
||||||
|
if (ia! > ib!) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cache ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function readCache(): UpdateCheckResult | null {
|
||||||
|
try {
|
||||||
|
if (!existsSync(CACHE_FILE)) return null;
|
||||||
|
const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as UpdateCheckResult;
|
||||||
|
const age = Date.now() - new Date(raw.checkedAt).getTime();
|
||||||
|
if (age > CACHE_TTL_MS) return null;
|
||||||
|
return raw;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCache(result: UpdateCheckResult): void {
|
||||||
|
try {
|
||||||
|
mkdirSync(CACHE_DIR, { recursive: true });
|
||||||
|
writeFileSync(CACHE_FILE, JSON.stringify(result, null, 2) + '\n', 'utf-8');
|
||||||
|
} catch {
|
||||||
|
// Best-effort — cache is not critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently installed version of @mosaic/cli.
|
||||||
|
* Returns empty string if not installed.
|
||||||
|
*/
|
||||||
|
export function getInstalledVersion(): string {
|
||||||
|
// Fast path: check via package.json require chain
|
||||||
|
try {
|
||||||
|
const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000);
|
||||||
|
if (raw) {
|
||||||
|
const data = JSON.parse(raw) as {
|
||||||
|
dependencies?: Record<string, { version?: string }>;
|
||||||
|
};
|
||||||
|
return data?.dependencies?.[CLI_PKG]?.version ?? '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the latest published version from the Gitea npm registry.
|
||||||
|
* Returns empty string on failure.
|
||||||
|
*/
|
||||||
|
export function getLatestVersion(): string {
|
||||||
|
return npmExec(`view ${CLI_PKG} version --registry=${REGISTRY}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform an update check — uses cache when fresh, otherwise hits registry.
|
||||||
|
* Never throws.
|
||||||
|
*/
|
||||||
|
export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult {
|
||||||
|
if (!options?.skipCache) {
|
||||||
|
const cached = readCache();
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = getInstalledVersion();
|
||||||
|
const latest = getLatestVersion();
|
||||||
|
const updateAvailable = !!(current && latest && semverLt(current, latest));
|
||||||
|
|
||||||
|
const result: UpdateCheckResult = {
|
||||||
|
current,
|
||||||
|
latest,
|
||||||
|
updateAvailable,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
registry: REGISTRY,
|
||||||
|
};
|
||||||
|
|
||||||
|
writeCache(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a human-readable update notice. Returns empty string if up-to-date.
|
||||||
|
*/
|
||||||
|
export function formatUpdateNotice(result: UpdateCheckResult): string {
|
||||||
|
if (!result.updateAvailable) return '';
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'',
|
||||||
|
'╭─────────────────────────────────────────────────╮',
|
||||||
|
'│ │',
|
||||||
|
`│ Update available: ${result.current} → ${result.latest}`.padEnd(50) + '│',
|
||||||
|
'│ │',
|
||||||
|
'│ Run: bash tools/install.sh │',
|
||||||
|
'│ Or: npm i -g @mosaic/cli@latest │',
|
||||||
|
'│ │',
|
||||||
|
'╰─────────────────────────────────────────────────╯',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-blocking update check that prints a notice to stderr if an update
|
||||||
|
* is available. Designed to be called at CLI startup without delaying
|
||||||
|
* the user.
|
||||||
|
*/
|
||||||
|
export function backgroundUpdateCheck(): void {
|
||||||
|
try {
|
||||||
|
const result = checkForUpdate();
|
||||||
|
const notice = formatUpdateNotice(result);
|
||||||
|
if (notice) {
|
||||||
|
process.stderr.write(notice);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — never block the user
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,18 @@ source "$SCRIPT_DIR/common.sh"
|
|||||||
ensure_repo_root
|
ensure_repo_root
|
||||||
load_repo_hooks
|
load_repo_hooks
|
||||||
|
|
||||||
|
# ─── Update check (non-blocking) ────────────────────────────────────────────
|
||||||
|
if command -v mosaic &>/dev/null; then
|
||||||
|
if mosaic update --check 2>/dev/null; then
|
||||||
|
: # up to date
|
||||||
|
elif [[ $? -eq 2 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "[agent-framework] ⚠ A newer version of Mosaic CLI is available."
|
||||||
|
echo "[agent-framework] Run: mosaic update or bash tools/install.sh"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && has_remote; then
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && has_remote; then
|
||||||
if git diff --quiet && git diff --cached --quiet; then
|
if git diff --quiet && git diff --cached --quiet; then
|
||||||
run_step "Pull latest changes" git pull --rebase
|
run_step "Pull latest changes" git pull --rebase
|
||||||
|
|||||||
218
tools/install.sh
Executable file
218
tools/install.sh
Executable file
@@ -0,0 +1,218 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ─── Mosaic Stack Installer / Upgrader ────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Remote install (recommended):
|
||||||
|
# bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
||||||
|
#
|
||||||
|
# Remote install (alternative — use -s -- to pass flags):
|
||||||
|
# curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh | bash -s --
|
||||||
|
# curl -fsSL ... | bash -s -- --check
|
||||||
|
#
|
||||||
|
# Local (from repo checkout):
|
||||||
|
# bash tools/install.sh
|
||||||
|
# bash tools/install.sh --check # version check only
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# MOSAIC_REGISTRY — npm registry URL (default: Gitea instance)
|
||||||
|
# MOSAIC_SCOPE — npm scope (default: @mosaic)
|
||||||
|
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
|
||||||
|
# MOSAIC_NO_COLOR — disable colour (set to 1)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The entire script is wrapped in main() so that bash reads it fully into
|
||||||
|
# memory before execution — required for safe curl-pipe usage.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
main() {
|
||||||
|
|
||||||
|
# ─── constants ────────────────────────────────────────────────────────────────
|
||||||
|
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaic/npm/}"
|
||||||
|
SCOPE="${MOSAIC_SCOPE:-@mosaic}"
|
||||||
|
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
|
||||||
|
CLI_PKG="${SCOPE}/cli"
|
||||||
|
|
||||||
|
# ─── colours ──────────────────────────────────────────────────────────────────
|
||||||
|
# Detect colour support: works in terminals and bash <(curl …), but correctly
|
||||||
|
# disables when piped through a non-tty (curl … | bash).
|
||||||
|
if [[ "${MOSAIC_NO_COLOR:-0}" == "1" ]] || ! [[ -t 1 ]]; then
|
||||||
|
R="" G="" Y="" B="" C="" DIM="" RESET=""
|
||||||
|
else
|
||||||
|
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m'
|
||||||
|
B=$'\033[0;34m' C=$'\033[0;36m' DIM=$'\033[2m' RESET=$'\033[0m'
|
||||||
|
fi
|
||||||
|
|
||||||
|
info() { echo "${B}ℹ${RESET} $*"; }
|
||||||
|
ok() { echo "${G}✔${RESET} $*"; }
|
||||||
|
warn() { echo "${Y}⚠${RESET} $*"; }
|
||||||
|
fail() { echo "${R}✖${RESET} $*" >&2; }
|
||||||
|
dim() { echo "${DIM}$*${RESET}"; }
|
||||||
|
|
||||||
|
# ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
if ! command -v "$1" &>/dev/null; then
|
||||||
|
fail "Required command not found: $1"
|
||||||
|
echo " Install it and re-run this script."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the installed version of @mosaic/cli (empty string if not installed)
|
||||||
|
installed_version() {
|
||||||
|
local json
|
||||||
|
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
|
||||||
|
if [[ -n "$json" ]]; then
|
||||||
|
# Feed json via heredoc, not stdin pipe — safe in curl-pipe context
|
||||||
|
node -e "
|
||||||
|
const d = JSON.parse(process.argv[1]);
|
||||||
|
const v = d?.dependencies?.['${CLI_PKG}']?.version ?? '';
|
||||||
|
process.stdout.write(v);
|
||||||
|
" "$json" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the latest published version from the registry
|
||||||
|
latest_version() {
|
||||||
|
npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compare two semver strings: returns 0 (true) if $1 < $2
|
||||||
|
version_lt() {
|
||||||
|
# Semver-aware comparison via node (handles pre-release correctly)
|
||||||
|
node -e "
|
||||||
|
const a=process.argv[1], b=process.argv[2];
|
||||||
|
const sp = v => { const i=v.indexOf('-'); return i===-1 ? [v,null] : [v.slice(0,i),v.slice(i+1)]; };
|
||||||
|
const [cA,pA]=sp(a.replace(/^v/,'')), [cB,pB]=sp(b.replace(/^v/,''));
|
||||||
|
const nA=cA.split('.').map(Number), nB=cB.split('.').map(Number);
|
||||||
|
for(let i=0;i<3;i++){if((nA[i]||0)<(nB[i]||0))process.exit(0);if((nA[i]||0)>(nB[i]||0))process.exit(1);}
|
||||||
|
if(pA!==null&&pB===null)process.exit(0);
|
||||||
|
if(pA===null)process.exit(1);
|
||||||
|
if(pA<pB)process.exit(0);
|
||||||
|
process.exit(1);
|
||||||
|
" "$1" "$2" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── preflight ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
require_cmd node
|
||||||
|
require_cmd npm
|
||||||
|
|
||||||
|
NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')"
|
||||||
|
if [[ "$NODE_MAJOR" -lt 20 ]]; then
|
||||||
|
fail "Node.js >= 20 required (found v$(node --version))"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── ensure prefix directory exists ──────────────────────────────────────────
|
||||||
|
|
||||||
|
if [[ ! -d "$PREFIX" ]]; then
|
||||||
|
info "Creating global prefix directory: $PREFIX"
|
||||||
|
mkdir -p "$PREFIX"/{bin,lib}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── ensure npmrc scope mapping ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
NPMRC="$HOME/.npmrc"
|
||||||
|
SCOPE_LINE="${SCOPE}:registry=${REGISTRY}"
|
||||||
|
|
||||||
|
if ! grep -qF "$SCOPE_LINE" "$NPMRC" 2>/dev/null; then
|
||||||
|
info "Adding ${SCOPE} registry to $NPMRC"
|
||||||
|
echo "$SCOPE_LINE" >> "$NPMRC"
|
||||||
|
ok "Registry configured"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure prefix is set (only if no prefix= line exists yet)
|
||||||
|
if ! grep -qF "prefix=$PREFIX" "$NPMRC" 2>/dev/null; then
|
||||||
|
if ! grep -q '^prefix=' "$NPMRC" 2>/dev/null; then
|
||||||
|
echo "prefix=$PREFIX" >> "$NPMRC"
|
||||||
|
info "Set npm global prefix to $PREFIX"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure PREFIX/bin is on PATH
|
||||||
|
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
|
||||||
|
warn "$PREFIX/bin is not on your PATH"
|
||||||
|
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── version check ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
info "Checking versions…"
|
||||||
|
|
||||||
|
CURRENT="$(installed_version)"
|
||||||
|
LATEST="$(latest_version)"
|
||||||
|
|
||||||
|
if [[ -z "$LATEST" ]]; then
|
||||||
|
fail "Could not reach registry at $REGISTRY"
|
||||||
|
fail "Check network connectivity and registry URL."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [[ -n "$CURRENT" ]]; then
|
||||||
|
dim " Installed: ${CLI_PKG}@${CURRENT}"
|
||||||
|
else
|
||||||
|
dim " Installed: (none)"
|
||||||
|
fi
|
||||||
|
dim " Latest: ${CLI_PKG}@${LATEST}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --check flag: just report, don't install
|
||||||
|
if [[ "${1:-}" == "--check" ]]; then
|
||||||
|
if [[ -z "$CURRENT" ]]; then
|
||||||
|
warn "Not installed. Run without --check to install."
|
||||||
|
exit 1
|
||||||
|
elif [[ "$CURRENT" == "$LATEST" ]]; then
|
||||||
|
ok "Up to date."
|
||||||
|
exit 0
|
||||||
|
elif version_lt "$CURRENT" "$LATEST"; then
|
||||||
|
warn "Update available: $CURRENT → $LATEST"
|
||||||
|
exit 2
|
||||||
|
else
|
||||||
|
ok "Up to date (or ahead of registry)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── install / upgrade ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if [[ -z "$CURRENT" ]]; then
|
||||||
|
info "Installing ${CLI_PKG}@${LATEST}…"
|
||||||
|
elif [[ "$CURRENT" == "$LATEST" ]]; then
|
||||||
|
ok "Already at latest version ($LATEST). Nothing to do."
|
||||||
|
exit 0
|
||||||
|
elif version_lt "$CURRENT" "$LATEST"; then
|
||||||
|
info "Upgrading ${CLI_PKG}: $CURRENT → $LATEST…"
|
||||||
|
else
|
||||||
|
ok "Installed version ($CURRENT) is at or ahead of registry ($LATEST)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm install -g "${CLI_PKG}@${LATEST}" \
|
||||||
|
--registry="$REGISTRY" \
|
||||||
|
--prefix="$PREFIX" \
|
||||||
|
2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "Mosaic CLI installed: $(installed_version)"
|
||||||
|
dim " Binary: $PREFIX/bin/mosaic"
|
||||||
|
|
||||||
|
# ─── post-install: run wizard if first install ───────────────────────────────
|
||||||
|
|
||||||
|
if [[ -z "$CURRENT" ]]; then
|
||||||
|
echo ""
|
||||||
|
info "First install detected."
|
||||||
|
if [[ -t 0 ]] && [[ -t 1 ]]; then
|
||||||
|
echo " Run ${C}mosaic wizard${RESET} to set up your configuration."
|
||||||
|
else
|
||||||
|
dim " Run 'mosaic wizard' to set up your configuration."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "Done."
|
||||||
|
|
||||||
|
} # end main
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user