* test(#4091): RED — worker cache write must be rename-atomic Structural regression guard: gsd-check-update-worker.js must not write the shared per-package cache in place; assert temp+rename publish shape plus a behavioral no-residue e2e. * fix(#4091): publish update-check cache via atomic temp+rename writeFileSync on the shared per-package cache truncates before writing, so concurrent cross-runtime readers (statusline/banner) could see a torn or empty record. Stage under a unique same-directory temp and rename into place — POSIX rename(2) is atomic, so readers see the old or new record, never a partial one. Degrade policy (#3582) unchanged: errors swallowed, temp best-effort removed. * refactor(#4091): hoist temp path, tighten uniqueness assertion (review) * changeset(#4091): add fragment (pr backfill to follow) * changeset(#4091): backfill PR 4313 --------- Co-authored-by: sim <sim@local>
178 lines
8.9 KiB
JavaScript
178 lines
8.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// gsd-hook-version: {{GSD_VERSION}}
|
|
// Background worker spawned by gsd-check-update.js (SessionStart hook).
|
|
// Checks for GSD updates and stale hooks, writes result to cache file.
|
|
// Receives paths via environment variables set by the parent hook.
|
|
//
|
|
// Using a separate file (rather than node -e '<inline code>') avoids the
|
|
// template-literal regex-escaping problem: regex source is plain JS here.
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// #3582: gsd-core/bin/lib/semver-compare.cjs and package-identity.cjs (and,
|
|
// transitively, check-latest-version.cjs's own gsd-core/bin/lib/cli-exit.cjs
|
|
// + shell-command-projection.cjs) are tsc build artifacts (ADR-457),
|
|
// gitignored and absent on a raw plugin-marketplace / git-clone install that
|
|
// never ran `npm run build:lib`. This worker is a DETACHED SessionStart
|
|
// background process (spawned with stdio: 'ignore') — a build failure here
|
|
// must DEGRADE to the no-signal fallbacks below (mirroring the
|
|
// managed-hooks-registry.cjs degrade just below) so the worker still runs to
|
|
// completion and writes a result cache record, rather than dying silently
|
|
// with no visible signal and no cache-file write at all.
|
|
//
|
|
// This try/require/ensureRuntimeBuild/require/catch shape repeats (with
|
|
// different destructured names) in hooks/gsd-check-update.js and
|
|
// hooks/gsd-update-banner.js. It is deliberately NOT extracted into a shared
|
|
// hooks/lib/ helper: scripts/lint-hooks-runtime-build-seam.cjs enforces this
|
|
// exact seam textually, PER FILE — it greps each hooks/ file for its OWN
|
|
// literal `require('.../ensure-runtime-build.cjs')` + `ensureRuntimeBuild(`
|
|
// call co-occurring with its OWN literal `require('.../gsd-core/bin/lib/*.cjs')`.
|
|
// A generic helper taking the compiled module's path as a variable would move
|
|
// the literal compiled-lib require OUT of this file and into the helper,
|
|
// called with a non-literal argument — the scan's regex (see that script's
|
|
// "Known limitations") cannot see a require() called with a variable, so this
|
|
// file would then read as "requires nothing" and the lint would stop
|
|
// protecting it. A ceremony-only helper (just the ensureRuntimeBuild call,
|
|
// each caller keeping its own literal compiled-lib require) fails the SAME
|
|
// way from the other side: it would remove this file's own literal
|
|
// `require('.../ensure-runtime-build.cjs')` + `ensureRuntimeBuild(` call,
|
|
// which the lint also requires to be textually present in THIS file. Either
|
|
// shape needs the lint script itself widened to special-case the helper,
|
|
// which is a bigger, riskier change than the ~6 duplicated lines it would
|
|
// save; kept inline instead.
|
|
let isSemverNewer = () => false;
|
|
let checkLatestVersion = () => ({ ok: false });
|
|
let PACKAGE_NAME = null;
|
|
try {
|
|
const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
|
|
ensureRuntimeBuild();
|
|
({ isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs'));
|
|
// Latest-version lookup is delegated to the single deterministic adapter
|
|
// (#498). checkLatestVersion() owns the npm-view call, the timeout/semver
|
|
// policy, and the package name — sourced from the baked Package Identity seam.
|
|
// The previous `require('../package.json').name` (#378) never yielded a name in
|
|
// the installed tree — at the time it resolved to the synthetic
|
|
// {"type":"commonjs"} marker GSD wrote at the config root, which has no `.name`,
|
|
// so the background check never reported updates. Since #2544 GSD writes no
|
|
// marker there at all, so that require would now fail to resolve outright.
|
|
// Either way the name must come from the baked seam, never a walk-up.
|
|
({ checkLatestVersion } = require('../gsd-core/bin/check-latest-version.cjs'));
|
|
({ PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs'));
|
|
} catch (e) {
|
|
// Runtime library missing/broken and could not self-build — degrade to the
|
|
// no-signal fallbacks declared above; the worker still writes a result
|
|
// cache record (package_name: null, update_available: false).
|
|
}
|
|
// Authoritative list of managed hooks — shared with tests to retire source-grep
|
|
// assertions (pending-migration-to-typed-ir [#455]).
|
|
// NOTE: managed-hooks-registry.cjs must be in HOOKS_TO_COPY (scripts/build-hooks.js)
|
|
// so it is present in hooks/dist/ and ships to the installed runtime hooks/ dir.
|
|
// If it is missing (e.g., installed from an older dist), catch and degrade gracefully
|
|
// so the worker always proceeds to compute and write the result cache record.
|
|
let MANAGED_HOOKS = [];
|
|
try {
|
|
({ MANAGED_HOOKS } = require('./managed-hooks-registry.cjs'));
|
|
} catch (e) {
|
|
// Module not found in installed runtime — stale-hook detection degrades to
|
|
// no-op (empty list means no hooks are checked for staleness). The worker
|
|
// still runs and writes package_name / installed / latest / update_available.
|
|
}
|
|
|
|
const cacheFile = process.env.GSD_CACHE_FILE;
|
|
const projectVersionFile = process.env.GSD_PROJECT_VERSION_FILE;
|
|
const globalVersionFile = process.env.GSD_GLOBAL_VERSION_FILE;
|
|
|
|
// Check project directory first (local install), then global
|
|
let installed = '0.0.0';
|
|
let configDir = '';
|
|
try {
|
|
if (fs.existsSync(projectVersionFile)) {
|
|
installed = fs.readFileSync(projectVersionFile, 'utf8').trim();
|
|
configDir = path.dirname(path.dirname(projectVersionFile));
|
|
} else if (fs.existsSync(globalVersionFile)) {
|
|
installed = fs.readFileSync(globalVersionFile, 'utf8').trim();
|
|
configDir = path.dirname(path.dirname(globalVersionFile));
|
|
}
|
|
} catch (e) {}
|
|
|
|
// Check for stale hooks — compare hook version headers against installed VERSION
|
|
// Since #3023 the bundle directory name is resolved from __dirname (this
|
|
// worker is staged INSIDE the bundle), not assumed to be configDir/hooks —
|
|
// the directory name is runtime-descriptor-driven (e.g. `gsd-hooks/` for pi).
|
|
// Only check hooks that GSD currently ships — orphaned files from removed features
|
|
// (e.g., gsd-intel-*.js) must be ignored to avoid permanent stale warnings (#1750)
|
|
// MANAGED_HOOKS is imported from ./managed-hooks-registry.cjs above.
|
|
|
|
const staleHooks = [];
|
|
if (configDir) {
|
|
// #3023: the bundle's directory name is runtime-descriptor-driven (pi stages
|
|
// it as `gsd-hooks/`), so deriving it as `<configDir>/hooks` silently scanned
|
|
// nothing there. This worker is staged INSIDE the bundle, so __dirname is the
|
|
// bundle directory by construction — name-agnostic and one fewer assumption.
|
|
const hooksDir = __dirname;
|
|
try {
|
|
if (fs.existsSync(hooksDir)) {
|
|
const hookFiles = fs.readdirSync(hooksDir).filter(f => MANAGED_HOOKS.includes(f));
|
|
for (const hookFile of hookFiles) {
|
|
try {
|
|
const content = fs.readFileSync(path.join(hooksDir, hookFile), 'utf8');
|
|
// Match both JS (//) and bash (#) comment styles
|
|
const versionMatch = content.match(/(?:\/\/|#) gsd-hook-version:\s*(.+)/);
|
|
if (versionMatch) {
|
|
const hookVersion = versionMatch[1].trim();
|
|
if (isSemverNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
|
|
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
|
|
}
|
|
} else {
|
|
// No version header at all — definitely stale (pre-version-tracking)
|
|
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Single adapter for the registry lookup (#498). checkLatestVersion() routes
|
|
// through the shell-projection seam, which already owns the Windows shell-flag
|
|
// policy, the timeout, and semver validation. A non-ok result leaves latest
|
|
// null, exactly as the previous inline try/catch did.
|
|
let latest = null;
|
|
try {
|
|
const lv = checkLatestVersion();
|
|
if (lv && lv.ok) latest = lv.version;
|
|
} catch (e) {}
|
|
|
|
const result = {
|
|
update_available: latest && isSemverNewer(latest, installed),
|
|
installed,
|
|
latest: latest || 'unknown',
|
|
checked: Math.floor(Date.now() / 1000),
|
|
stale_hooks: staleHooks.length > 0 ? staleHooks : undefined,
|
|
package_name: PACKAGE_NAME,
|
|
};
|
|
|
|
if (cacheFile) {
|
|
// #4091: the cache file is shared per-PACKAGE across every runtime's worker
|
|
// (#607/#1421), so concurrent statusline/banner readers parse it while this
|
|
// worker writes it. A direct writeFileSync truncates before writing — a
|
|
// reader landing mid-write sees a torn/empty record (its JSON.parse catch
|
|
// swallows it, so the symptom is an intermittently blank update segment).
|
|
// Publish atomically instead: stage under a unique same-directory temp
|
|
// (same filesystem, so rename(2) is atomic — readers see the old or the new
|
|
// record, never a partial one), then renameSync into place. Failure policy
|
|
// is unchanged (#3582 degrade): any error is swallowed and the temp, if
|
|
// left behind, is best-effort removed.
|
|
const tmp = cacheFile + '.tmp-' + process.pid;
|
|
try {
|
|
fs.writeFileSync(tmp, JSON.stringify(result));
|
|
fs.renameSync(tmp, cacheFile);
|
|
} catch (e) {
|
|
try {
|
|
fs.rmSync(tmp, { force: true });
|
|
} catch (e2) {}
|
|
}}
|