Compare commits

...

1 Commits

Author SHA1 Message Date
Jarvis
c39fa065fc fix: simplify updater to @mosaic/mosaic only, add explicit tea repo/login flags (#387)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
BREAKING CHANGE: Remove legacy @mosaic/cli fallback from update-checker.
The updater now targets only @mosaic/mosaic — legacy package compatibility
is out of scope per approved breaking change.

- update-checker.ts: remove LEGACY_PKG, INSTALLED_PACKAGE_ORDER, candidate
  iteration, and cache key comparison. Single query for @mosaic/mosaic only.
- update-checker.test.ts: remove legacy fallback and cross-contamination
  tests. Add test asserting no @mosaic/cli queries are made.
- pr-merge.sh: add --repo and --login flags to tea merge command; resolve
  OWNER/REPO before case block.
- issue-close.sh: add --repo and --login flags to tea issue commands;
  resolve OWNER/REPO after detect_platform.
- pr-ci-wait.sh: no changes needed (uses curl, not tea).
- detect-platform.sh: no changes needed (provides get_repo_owner/name).

Ref: #387
2026-04-04 21:06:59 -05:00
5 changed files with 106 additions and 101 deletions

View File

@@ -0,0 +1,54 @@
# Scratchpad — issue #387 updater simplification + Gitea wrapper repo context
- Objective: simplify updater behavior to `@mosaic/mosaic` only, and fix Gitea wrapper scripts so merge/CI/issue operations work reliably when tea needs explicit repo/login context.
- Scope:
- `packages/mosaic/src/runtime/update-checker.ts`
- `packages/mosaic/__tests__/update-checker.test.ts`
- any package metadata/version bumps needed
- repo-source git wrappers under `packages/mosaic/framework/tools/git/`
- Constraints:
- Jason approved breaking changes; legacy `@mosaic/cli` support is out of scope.
- Keep changes focused and mergeable.
- Acceptance:
- updater only targets `@mosaic/mosaic`
- wrapper path works on Gitea in this environment without manual repo guessing
- PR merges squash-only after green CI
## Progress
### 2026-04-04
#### Update Checker Simplification (DONE)
- Removed `LEGACY_PKG`, `INSTALLED_PACKAGE_ORDER` constants — only `PKG = '@mosaic/mosaic'` remains
- `getInstalledVersion()` — removed loop over multiple packages, only checks `@mosaic/mosaic`
- `getLatestVersion()` — removed `installedPackage` parameter and candidate iteration; single query for `@mosaic/mosaic`
- `checkForUpdate()` — removed `currentPackage`-based cache key comparison; cache is now package-agnostic
- `RegistryCache` — removed `currentPackage` and `targetPackage` fields
- `formatUpdateNotice()` — removed `targetChanged` branch (package migration notice no longer relevant)
- All legacy fallback/compatibility logic removed
#### Test Updates (DONE)
- Removed legacy `@mosaic/cli` fallback test
- Removed cache cross-contamination test (was testing legacy→modern package transition)
- Added `does not query legacy @mosaic/cli package` test — asserts no `@mosaic/cli` npm commands are issued
- Added `returns empty result when package is not installed` test
- All 8 tests pass ✅
#### Gitea Wrapper Fixes (DONE)
- `pr-merge.sh`:
- Added `OWNER=$(get_repo_owner)` / `REPO=$(get_repo_name)` before case block
- tea merge command now includes `--repo $OWNER/$REPO --login ${GITEA_LOGIN:-mosaicstack}`
- `issue-close.sh`:
- Added `OWNER=$(get_repo_owner)` / `REPO=$(get_repo_name)` after detect_platform
- Both `tea issue comment` and `tea issue close` now include `--repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"`
- `pr-ci-wait.sh`: No changes needed — uses curl API calls (not tea), already passes owner/repo correctly
- `detect-platform.sh`: No changes needed — provides the `get_repo_owner`/`get_repo_name` functions used by wrappers
#### Verification
- `vitest run` — 8/8 tests pass
- `tsc --noEmit` — no errors in update-checker.ts (pre-existing workspace dep errors unrelated)
- `eslint` — clean, no warnings

View File

@@ -88,19 +88,18 @@ describe('formatUpdateNotice', () => {
expect(notice).toContain('Update available'); expect(notice).toContain('Update available');
}); });
it('uses @mosaic/mosaic hints for modern installs', async () => { it('uses @mosaic/mosaic for installs', async () => {
execSyncMock.mockImplementation((command: string) => { execSyncMock.mockImplementation((command: string) => {
if (command.includes('ls -g --depth=0 --json')) { if (command.includes('ls -g --depth=0 --json')) {
return JSON.stringify({ return JSON.stringify({
dependencies: { dependencies: {
'@mosaic/mosaic': { version: '0.0.17' }, '@mosaic/mosaic': { version: '0.0.19' },
'@mosaic/cli': { version: '0.0.16' },
}, },
}); });
} }
if (command.includes('view @mosaic/mosaic version')) { if (command.includes('view @mosaic/mosaic version')) {
return '0.0.18'; return '0.0.20';
} }
throw new Error(`Unexpected command: ${command}`); throw new Error(`Unexpected command: ${command}`);
@@ -110,28 +109,29 @@ describe('formatUpdateNotice', () => {
const result = checkForUpdate({ skipCache: true }); const result = checkForUpdate({ skipCache: true });
const notice = formatUpdateNotice(result); const notice = formatUpdateNotice(result);
expect(result.current).toBe('0.0.17'); expect(result.current).toBe('0.0.19');
expect(result.latest).toBe('0.0.18'); expect(result.latest).toBe('0.0.20');
expect(result.currentPackage).toBe('@mosaic/mosaic');
expect(result.targetPackage).toBe('@mosaic/mosaic');
expect(notice).toContain('@mosaic/mosaic@latest'); expect(notice).toContain('@mosaic/mosaic@latest');
expect(notice).not.toContain('@mosaic/cli@latest');
}); });
it('falls back to @mosaic/mosaic for legacy @mosaic/cli installs when cli is unavailable', async () => { it('does not query legacy @mosaic/cli package', async () => {
execSyncMock.mockImplementation((command: string) => { execSyncMock.mockImplementation((command: string) => {
if (command.includes('view @mosaic/cli')) {
throw new Error('Should not query @mosaic/cli');
}
if (command.includes('ls -g --depth=0 --json')) { if (command.includes('ls -g --depth=0 --json')) {
return JSON.stringify({ return JSON.stringify({
dependencies: { dependencies: {
'@mosaic/cli': { version: '0.0.16' }, '@mosaic/mosaic': { version: '0.0.19' },
}, },
}); });
} }
if (command.includes('view @mosaic/cli version')) {
throw new Error('not found');
}
if (command.includes('view @mosaic/mosaic version')) { if (command.includes('view @mosaic/mosaic version')) {
return '0.0.17'; return '0.0.20';
} }
throw new Error(`Unexpected command: ${command}`); throw new Error(`Unexpected command: ${command}`);
@@ -139,57 +139,31 @@ describe('formatUpdateNotice', () => {
const { checkForUpdate } = await importUpdateChecker(); const { checkForUpdate } = await importUpdateChecker();
const result = checkForUpdate({ skipCache: true }); const result = checkForUpdate({ skipCache: true });
const notice = formatUpdateNotice(result);
expect(result.current).toBe('0.0.16'); expect(result.targetPackage).toBe('@mosaic/mosaic');
expect(result.latest).toBe('0.0.17'); expect(result.latest).toBe('0.0.20');
expect(notice).toContain('@mosaic/mosaic@latest'); // Verify no @mosaic/cli queries were made
const calls = execSyncMock.mock.calls.map((c: any[]) => c[0] as string);
expect(calls.some((c) => c.includes('@mosaic/cli'))).toBe(false);
}); });
it('does not reuse a cached modern-package result for a legacy install', async () => { it('returns empty result when package is not installed', async () => {
let installedPackage = '@mosaic/mosaic';
execSyncMock.mockImplementation((command: string) => { execSyncMock.mockImplementation((command: string) => {
if (command.includes('ls -g --depth=0 --json')) { if (command.includes('ls -g --depth=0 --json')) {
return JSON.stringify({ return JSON.stringify({ dependencies: {} });
dependencies:
installedPackage === '@mosaic/mosaic'
? { '@mosaic/mosaic': { version: '0.0.17' } }
: { '@mosaic/cli': { version: '0.0.16' } },
});
} }
if (command.includes('view @mosaic/mosaic version')) { if (command.includes('view @mosaic/mosaic version')) {
return installedPackage === '@mosaic/mosaic' ? '0.0.18' : '0.0.17'; return '';
}
if (command.includes('view @mosaic/cli version')) {
throw new Error('not found');
} }
throw new Error(`Unexpected command: ${command}`); throw new Error(`Unexpected command: ${command}`);
}); });
const { checkForUpdate } = await importUpdateChecker(); const { checkForUpdate } = await importUpdateChecker();
const result = checkForUpdate({ skipCache: true });
const modernResult = checkForUpdate(); expect(result.current).toBe('');
installedPackage = '@mosaic/cli'; expect(result.updateAvailable).toBe(false);
const legacyResult = checkForUpdate();
expect(modernResult.currentPackage).toBe('@mosaic/mosaic');
expect(modernResult.targetPackage).toBe('@mosaic/mosaic');
expect(modernResult.latest).toBe('0.0.18');
expect(legacyResult.currentPackage).toBe('@mosaic/cli');
expect(legacyResult.targetPackage).toBe('@mosaic/mosaic');
expect(legacyResult.latest).toBe('0.0.17');
expect(execSyncMock).toHaveBeenCalledWith(
expect.stringContaining('view @mosaic/cli version'),
expect.any(Object),
);
expect(execSyncMock).toHaveBeenCalledWith(
expect.stringContaining('view @mosaic/mosaic version'),
expect.any(Object),
);
}); });
}); });

View File

@@ -45,6 +45,8 @@ fi
# Detect platform and close issue # Detect platform and close issue
detect_platform detect_platform
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
if [[ "$PLATFORM" == "github" ]]; then if [[ "$PLATFORM" == "github" ]]; then
if [[ -n "$COMMENT" ]]; then if [[ -n "$COMMENT" ]]; then
@@ -54,9 +56,9 @@ if [[ "$PLATFORM" == "github" ]]; then
echo "Closed GitHub issue #$ISSUE_NUMBER" echo "Closed GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
if [[ -n "$COMMENT" ]]; then if [[ -n "$COMMENT" ]]; then
tea issue comment "$ISSUE_NUMBER" "$COMMENT" tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"
fi fi
tea issue close "$ISSUE_NUMBER" tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"
echo "Closed Gitea issue #$ISSUE_NUMBER" echo "Closed Gitea issue #$ISSUE_NUMBER"
else else
echo "Error: Unknown platform" echo "Error: Unknown platform"

View File

@@ -89,6 +89,8 @@ if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
fi fi
PLATFORM=$(detect_platform) PLATFORM=$(detect_platform)
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
case "$PLATFORM" in case "$PLATFORM" in
github) github)
@@ -97,8 +99,7 @@ case "$PLATFORM" in
eval "$CMD" eval "$CMD"
;; ;;
gitea) gitea)
# tea pr merge syntax CMD="tea pr merge $PR_NUMBER --style squash --repo $OWNER/$REPO --login ${GITEA_LOGIN:-mosaicstack}"
CMD="tea pr merge $PR_NUMBER --style squash"
# Delete branch after merge if requested # Delete branch after merge if requested
if [[ "$DELETE_BRANCH" == true ]]; then if [[ "$DELETE_BRANCH" == true ]]; then

View File

@@ -1,6 +1,6 @@
/** /**
* Mosaic update checker — compares the installed Mosaic package against the * Mosaic update checker — compares the installed @mosaic/mosaic package
* Gitea npm registry and reports when an upgrade is available. * against the Gitea npm registry and reports when an upgrade is available.
* *
* Used by: * Used by:
* - CLI startup (non-blocking background check) * - CLI startup (non-blocking background check)
@@ -40,9 +40,7 @@ export interface UpdateCheckResult {
// ─── Constants ────────────────────────────────────────────────────────────── // ─── Constants ──────────────────────────────────────────────────────────────
const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/'; const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
const MODERN_PKG = '@mosaic/mosaic'; const PKG = '@mosaic/mosaic';
const LEGACY_PKG = '@mosaic/cli';
const INSTALLED_PACKAGE_ORDER = [MODERN_PKG, LEGACY_PKG] as const;
const CACHE_DIR = join(homedir(), '.cache', 'mosaic'); const CACHE_DIR = join(homedir(), '.cache', 'mosaic');
const CACHE_FILE = join(CACHE_DIR, 'update-check.json'); const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -131,20 +129,17 @@ export function semverLt(a: string, b: string): boolean {
/** Cache stores only the latest registry version (the expensive network call). /** Cache stores only the latest registry version (the expensive network call).
* The installed version is always checked fresh — it's a local `npm ls`. */ * The installed version is always checked fresh — it's a local `npm ls`. */
interface RegistryCache { interface RegistryCache {
currentPackage?: string;
latest: string; latest: string;
targetPackage?: string;
checkedAt: string; checkedAt: string;
registry: string; registry: string;
} }
function readCache(currentPackage: string): RegistryCache | null { function readCache(): RegistryCache | null {
try { try {
if (!existsSync(CACHE_FILE)) return null; if (!existsSync(CACHE_FILE)) return null;
const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as RegistryCache; const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as RegistryCache;
const age = Date.now() - new Date(raw.checkedAt).getTime(); const age = Date.now() - new Date(raw.checkedAt).getTime();
if (age > CACHE_TTL_MS) return null; if (age > CACHE_TTL_MS) return null;
if ((raw.currentPackage || '') !== currentPackage) return null;
return raw; return raw;
} catch { } catch {
return null; return null;
@@ -163,22 +158,18 @@ function writeCache(entry: RegistryCache): void {
// ─── Public API ───────────────────────────────────────────────────────────── // ─── Public API ─────────────────────────────────────────────────────────────
/** /**
* Get the currently installed Mosaic package version. * Get the currently installed @mosaic/mosaic version.
* Prefers the consolidated @mosaic/mosaic package over legacy @mosaic/cli.
*/ */
export function getInstalledVersion(): { name: string; version: string } { export function getInstalledVersion(): { name: string; version: string } {
// Fast path: check via package.json require chain
try { try {
const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000); const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000);
if (raw) { if (raw) {
const data = JSON.parse(raw) as { const data = JSON.parse(raw) as {
dependencies?: Record<string, { version?: string }>; dependencies?: Record<string, { version?: string }>;
}; };
for (const pkg of INSTALLED_PACKAGE_ORDER) { const version = data?.dependencies?.[PKG]?.version;
const version = data?.dependencies?.[pkg]?.version; if (version) {
if (version) { return { name: PKG, version };
return { name: pkg, version };
}
} }
} }
} catch { } catch {
@@ -188,27 +179,19 @@ export function getInstalledVersion(): { name: string; version: string } {
} }
/** /**
* Fetch the latest published version from the Gitea npm registry. * Fetch the latest published @mosaic/mosaic version from the Gitea npm registry.
* For legacy @mosaic/cli installs, try the legacy package first and fall back
* to @mosaic/mosaic to support the CLI -> mosaic package consolidation.
* Returns empty string on failure. * Returns empty string on failure.
*/ */
export function getLatestVersion(installedPackage = ''): { name: string; version: string } { export function getLatestVersion(): { name: string; version: string } {
const candidates = const version = npmExec(`view ${PKG} version --registry=${REGISTRY}`);
installedPackage === LEGACY_PKG ? [LEGACY_PKG, MODERN_PKG] : [MODERN_PKG, LEGACY_PKG]; if (version) {
return { name: PKG, version };
for (const pkg of candidates) {
const version = npmExec(`view ${pkg} version --registry=${REGISTRY}`);
if (version) {
return { name: pkg, version };
}
} }
return { name: '', version: '' }; return { name: '', version: '' };
} }
export function getInstallCommand(result: Pick<UpdateCheckResult, 'targetPackage'>): string { export function getInstallCommand(result: Pick<UpdateCheckResult, 'targetPackage'>): string {
return `npm i -g ${result.targetPackage || MODERN_PKG}@latest`; return `npm i -g ${result.targetPackage || PKG}@latest`;
} }
/** /**
@@ -225,31 +208,27 @@ export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckRe
let checkedAt: string; let checkedAt: string;
if (!options?.skipCache) { if (!options?.skipCache) {
const cached = readCache(currentInfo.name); const cached = readCache();
if (cached) { if (cached) {
latestInfo = { latestInfo = {
name: cached.targetPackage || MODERN_PKG, name: PKG,
version: cached.latest, version: cached.latest,
}; };
checkedAt = cached.checkedAt; checkedAt = cached.checkedAt;
} else { } else {
latestInfo = getLatestVersion(currentInfo.name); latestInfo = getLatestVersion();
checkedAt = new Date().toISOString(); checkedAt = new Date().toISOString();
writeCache({ writeCache({
currentPackage: currentInfo.name,
latest: latestInfo.version, latest: latestInfo.version,
targetPackage: latestInfo.name,
checkedAt, checkedAt,
registry: REGISTRY, registry: REGISTRY,
}); });
} }
} else { } else {
latestInfo = getLatestVersion(currentInfo.name); latestInfo = getLatestVersion();
checkedAt = new Date().toISOString(); checkedAt = new Date().toISOString();
writeCache({ writeCache({
currentPackage: currentInfo.name,
latest: latestInfo.version, latest: latestInfo.version,
targetPackage: latestInfo.name,
checkedAt, checkedAt,
registry: REGISTRY, registry: REGISTRY,
}); });
@@ -257,9 +236,9 @@ export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckRe
return { return {
current, current,
currentPackage: currentInfo.name, currentPackage: currentInfo.name || PKG,
latest: latestInfo.version, latest: latestInfo.version,
targetPackage: latestInfo.name || MODERN_PKG, targetPackage: latestInfo.name || PKG,
updateAvailable: !!(current && latestInfo.version && semverLt(current, latestInfo.version)), updateAvailable: !!(current && latestInfo.version && semverLt(current, latestInfo.version)),
checkedAt, checkedAt,
registry: REGISTRY, registry: REGISTRY,
@@ -273,17 +252,12 @@ export function formatUpdateNotice(result: UpdateCheckResult): string {
if (!result.updateAvailable) return ''; if (!result.updateAvailable) return '';
const installCommand = getInstallCommand(result); const installCommand = getInstallCommand(result);
const targetChanged =
result.currentPackage && result.targetPackage && result.currentPackage !== result.targetPackage;
const lines = [ const lines = [
'', '',
'╭─────────────────────────────────────────────────╮', '╭─────────────────────────────────────────────────╮',
'│ │', '│ │',
`│ Update available: ${result.current}${result.latest}`.padEnd(50) + '│', `│ Update available: ${result.current}${result.latest}`.padEnd(50) + '│',
...(targetChanged
? [`│ Package target: ${result.currentPackage}${result.targetPackage}`.padEnd(50) + '│']
: []),
'│ │', '│ │',
'│ Run: bash tools/install.sh │', '│ Run: bash tools/install.sh │',
`│ Or: ${installCommand}`.padEnd(50) + '│', `│ Or: ${installCommand}`.padEnd(50) + '│',