Files
mvschwarz 6e0da5037c Presence is not meaning: three provisioning defects from a real VPS
cloud-impl found all three provisioning a real VPS, named the shared shape,
and reported two of them against their own code. They are the same error at three
layers: something EXISTS, so it is treated as valid, and the check cannot tell
"declared and incomplete" from "declared absent" or "present and unusable".

D11 — install-app.mjs refused a deliberate "no provider". `apps/workspace` declares
`provider: { required: false }` with a note saying it needs none, and the checker
read the KEY'S PRESENCE as a declaration: three failures against a manifest that
was documenting its own correctness. WORKSPACE was uninstallable on any box. A
provider is declared by a PACKAGE, not by a key existing. One shared predicate
rather than two edits, because two sites deciding one question is the drift shape
this repo has already paid for three times — reverting either site alone still
brings failures back, which is how I know both mattered.

D9 — the provider branch tested whether a lockfile EXISTS, not whether it WORKS,
and committed to `npm ci` on that basis. A stub lockfile locking nothing sailed
past the file test and died inside npm. Present-but-unusable is worse than absent:
absent fails where the branch already handles it. Now it tries the exact path,
SAYS the lockfile was unusable, and falls through. Verified against real npm and a
real stub lockfile, with a control proving npm ci genuinely refuses it.

D10 — dependencies were installed for every provider on disk regardless of which
apps were enabled, so a provider nobody asked for could stop the whole provision.

D10 IS ALSO A CORRECTION TO MY OWN CLAIM, and that is the part worth recording.
I shipped the curated four-app default saying "two providers, no ffmpeg" — in the
README, in the commit message, and in my report to orch. It was FALSE: the app list
narrowed, the provider install did not, so all seven providers' dependencies landed
anyway. I asserted a benefit I never measured, in the same change where I was
careful to log what the cap dropped. The claim is true now because this makes it
true, not because it was true then.

Resolution walks JSON in node and is read line by line, never word-split. It
covers both ways an app needs a backend: naming one, and REQUIRING a verb somebody
else serves — `calls` is provider-agnostic by design, so an unserved required call
means reconciliation refuses the install. Optional calls pull nothing, mirroring
the ladder in app-manifest.md where only `required: true` may start a provider no
app names as its own.

298 tests. The shell paths were driven directly against fixtures and real npm;
this targets Linux and the host is Darwin, so the provision was not run end to end.
2026-08-05 13:34:59 -07:00

74 lines
3.1 KiB
JavaScript

#!/usr/bin/env node
// Which provider directories does THIS set of apps actually need?
//
// node needed-providers.mjs <appsRoot> <app> [app...] -> one directory name per line
//
// WHY THIS EXISTS: provisioning installed dependencies for every provider on
// disk regardless of which apps were enabled, so a provider nobody asked for
// could stop the whole install. A box asking for four apps should not be
// stopped by the eleventh provider. Found by cloud-impl on a real VPS, where
// exactly that happened.
//
// WHY IT IS NODE AND NOT SHELL: this walks JSON and emits a set. Structured data
// does not round-trip through the shell's word list, and the caller reads the
// output line by line rather than splitting it.
//
// TWO WAYS AN APP NEEDS A PROVIDER, and only the first is obvious:
// 1. it NAMES one — `provider.package`
// 2. it REQUIRES A VERB somebody else serves — `calls` with `required: true`.
// `calls` is provider-agnostic by design (app-manifest.md), so the box works
// out who answers. An app whose required call is unserved is refused at
// reconciliation, and a provider whose deps were never installed cannot
// start to serve it.
// Optional calls deliberately pull nothing in: `required: false` grants no
// authority to start a provider, so it grants none to install one either.
import fs from "node:fs";
import path from "node:path";
const [appsRoot, ...apps] = process.argv.slice(2);
if (!appsRoot || !apps.length) {
console.error("usage: needed-providers.mjs <appsRoot> <app> [app...]");
process.exit(2);
}
const readJson = (p) => {
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
};
const dirOf = (pkg) => (typeof pkg === "string" && pkg ? path.basename(pkg) : null);
const providersDir = path.join(appsRoot, "providers");
const onDisk = fs.existsSync(providersDir)
? fs.readdirSync(providersDir).filter((d) => fs.statSync(path.join(providersDir, d)).isDirectory())
: [];
// verb -> provider directory, read from each provider's own declaration. The
// provider is authoritative about which verbs it answers; nothing else can say.
const answers = new Map();
for (const d of onDisk) {
const decl = readJson(path.join(providersDir, d, "provider.json"));
for (const v of decl?.verbs ?? []) if (!answers.has(v)) answers.set(v, d);
}
const needed = new Set();
for (const app of apps) {
const m = readJson(path.join(appsRoot, "apps", app, "app.json"));
if (!m) continue;
const direct = dirOf(m.provider?.package);
if (direct) needed.add(direct);
for (const [verb, spec] of Object.entries(m.calls ?? {})) {
if (!spec?.required) continue;
// A verb ending in `/` is a prefix match (app-manifest.md), so an exact
// lookup alone would miss `/api/export-status/<id>`-shaped declarations.
const who = answers.get(verb)
?? [...answers.entries()].find(([v]) => v.endsWith("/") && verb.startsWith(v))?.[1];
if (who) needed.add(who);
}
}
// Only ones that exist. A named-but-absent provider is reconciliation's problem
// to report, not this script's to invent.
for (const d of [...needed].sort()) if (onDisk.includes(d)) console.log(d);