@rightkit/release 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -84,22 +84,60 @@ export function featureClosureFindings(treeText, { forbidden = [...REGISTRY.forb
84
84
  // M7 lane E: rightkit-daemon `remote` (the HTTPS host with tokio, axum and hyper) may enter a release graph only for an app
85
85
  // that declares `releaseSwitches.remoteHost: true` (the Hetzner Pulse and PostRight server builds). `testing` implies `remote`,
86
86
  // so it is caught by the same line. Fail-closed: a value other than a boolean throws, and `false` or an absent key refuses.
87
+ // PK17-2: a `remote` that appears ONLY beneath the daemon `remote-loopback` feature (the loopback feature enables the shared
88
+ // router and auth internals) is not a host declaration; that case is judged by `remoteLoopbackFindings`, not here. Any other
89
+ // path to `remote` (a direct request, `testing`, another crate's feature) is still refused exactly as before.
87
90
  export const REMOTE_SERVICE_CRATE = "rightkit-daemon";
88
91
  export const REMOTE_SERVICE_FEATURE = "remote";
92
+ export const REMOTE_LOOPBACK_FEATURE = "remote-loopback";
93
+
94
+ // Feature lines of a `cargo tree -e features` text with their parent. Default `--prefix indent` output indents each level by
95
+ // four columns (`├── `, `│ `, ` `; `--charset ascii` keeps the width), so depth is the prefix width / 4 and the parent is
96
+ // the nearest earlier line one level up. A flat tree (`--prefix none`) has depth 0 everywhere, so no feature has a parent and
97
+ // every parent-dependent rule treats it as the direct (stricter) case: this fails closed.
98
+ const TREE_PREFIX = /^[\s│├└─|`+\\-]*/;
99
+ function featureTreeEntries(treeText) {
100
+ const entries = []; const stack = [];
101
+ for (const line of String(treeText).split(/\r?\n/)) {
102
+ if (!line.trim()) continue;
103
+ const depth = Math.floor(TREE_PREFIX.exec(line)[0].length / 4);
104
+ const m = FEATURE_LINE.exec(line);
105
+ const entry = { crate: m ? m[1] : null, feature: m ? m[2] : null, parent: depth > 0 ? (stack[depth - 1] ?? null) : null };
106
+ stack[depth] = entry; stack.length = depth + 1;
107
+ if (m) entries.push(entry);
108
+ }
109
+ return entries;
110
+ }
111
+ const impliedByLoopback = (entry) => entry.parent?.crate === REMOTE_SERVICE_CRATE && entry.parent?.feature === REMOTE_LOOPBACK_FEATURE;
112
+
89
113
  export function remoteServiceFindings(treeText, { remoteHost = false } = {}) {
90
114
  if (typeof remoteHost !== "boolean") throw new Error(`releaseSwitches.remoteHost must be true or false, got ${JSON.stringify(remoteHost)}`);
91
115
  if (remoteHost) return [];
92
116
  const findings = []; const seen = new Set();
93
- for (const line of String(treeText).split(/\r?\n/)) {
94
- const m = FEATURE_LINE.exec(line);
95
- if (!m || m[1] !== REMOTE_SERVICE_CRATE || m[2] !== REMOTE_SERVICE_FEATURE || seen.has(m[1])) continue;
96
- seen.add(m[1]);
117
+ for (const entry of featureTreeEntries(treeText)) {
118
+ if (entry.crate !== REMOTE_SERVICE_CRATE || entry.feature !== REMOTE_SERVICE_FEATURE || seen.has(entry.crate) || impliedByLoopback(entry)) continue;
119
+ seen.add(entry.crate);
97
120
  const id = "remote-feature-undeclared";
98
121
  findings.push({ layer: "feature-closure", rule: id, file: "<cargo tree>", reason: `${id}: crate \`${REMOTE_SERVICE_CRATE}\` has feature \`${REMOTE_SERVICE_FEATURE}\` enabled in the resolved graph, but the app does not declare releaseSwitches.remoteHost: true (only Pulse and PostRight server builds may enable it)` });
99
122
  }
100
123
  return findings;
101
124
  }
102
125
 
126
+ // PK17-2 / PK06-5: the daemon loopback feature (CodeRight's loopback HTTP + bearer host, loopback-only bind) is its own release
127
+ // declaration, `releaseSwitches.remoteLoopback: true`, distinct from `remoteHost`. Neither declaration implies the other: a server
128
+ // build with `remoteHost: true` still needs `remoteLoopback: true` to ship the loopback feature, and a loopback app that also
129
+ // enables `remote` on its own still needs `remoteHost: true`. Same fail-closed boolean rule as `remoteHost`.
130
+ export function remoteLoopbackFindings(treeText, { remoteLoopback = false } = {}) {
131
+ if (typeof remoteLoopback !== "boolean") throw new Error(`releaseSwitches.remoteLoopback must be true or false, got ${JSON.stringify(remoteLoopback)}`);
132
+ if (remoteLoopback) return [];
133
+ for (const entry of featureTreeEntries(treeText)) {
134
+ if (entry.crate !== REMOTE_SERVICE_CRATE || entry.feature !== REMOTE_LOOPBACK_FEATURE) continue;
135
+ const id = "remote-loopback-undeclared";
136
+ return [{ layer: "feature-closure", rule: id, file: "<cargo tree>", reason: `${id}: crate \`${REMOTE_SERVICE_CRATE}\` has feature \`${REMOTE_LOOPBACK_FEATURE}\` enabled in the resolved graph, but the app does not declare releaseSwitches.remoteLoopback: true (only a headless engine build that serves its own loopback API may enable it; releaseSwitches.remoteHost does not cover it)` }];
137
+ }
138
+ return [];
139
+ }
140
+
103
141
  // A build command (shell string or { cmd, args }) as one flat token list, whole arguments plus their shell re-tokenisation.
104
142
  export function commandTokens(command) {
105
143
  const parts = typeof command === "string" ? [command] : [command?.cmd, ...(command?.args ?? [])].filter((v) => v !== undefined).map(String);
@@ -445,7 +483,7 @@ export function scanSwitchPaths(paths, { denyMap = denyNamesFor() } = {}) {
445
483
  // Every layer is optional input; a layer given no input is skipped, except that `requireBinary` fails a scan that
446
484
  // found no native binary. `bypass: ["feature-closure"]` exists only for the negative fixtures that prove the binary
447
485
  // layer independently; release.mjs never passes it.
448
- export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, sourceRoots, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost, devtools } = {}) {
486
+ export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, sourceRoots, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost, remoteLoopback, devtools } = {}) {
449
487
  const findings = [];
450
488
  const bypassed = new Set(bypass);
451
489
  const denyMap = denyNamesFor({ appForbid: [...appForbid, ...(inventoryText != null ? inventoryDenyNames(inventoryText) : [])] });
@@ -453,6 +491,7 @@ export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, sou
453
491
  if (treeText != null) {
454
492
  findings.push(...featureClosureFindings(treeText, { allowDevtools: devtools?.mode === "content-only" }));
455
493
  findings.push(...remoteServiceFindings(treeText, { remoteHost }));
494
+ findings.push(...remoteLoopbackFindings(treeText, { remoteLoopback }));
456
495
  }
457
496
  for (const argv of commands) findings.push(...commandSwitchFindings(argv, { allowDevtools: devtools?.mode === "content-only" }));
458
497
  }
package/release.mjs CHANGED
@@ -54,6 +54,7 @@ import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mj
54
54
  import { runAndroidRelease, validateAndroidTarget } from "./android-release.mjs";
55
55
  import { runIosPackageRecipe } from "./ios-package-recipe.mjs";
56
56
  import { releaseSwitchInputs } from "./ios-build-context.mjs";
57
+ import { productionBuildEnv, productionModeProblems } from "./production-mode.mjs";
57
58
 
58
59
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
59
60
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -382,6 +383,13 @@ try {
382
383
  { label: "target.gradle", command: target.gradle },
383
384
  ] });
384
385
  } catch (error) { fail(error.message); }
386
+ // M12: the frontend build resolves in production mode only (development-only conditional exports such as
387
+ // `@rightkit/license/testing` never resolve). `run` pins NODE_ENV=production for build children; this refuses a
388
+ // Vite config that lists the `development` resolve condition explicitly, which the pin cannot override.
389
+ {
390
+ const modeProblems = productionModeProblems({ appRoot: root });
391
+ if (modeProblems.length) fail(`${config.app ?? "app"} release frontend is not production-only:\n- ${modeProblems.join("\n- ")}`);
392
+ }
385
393
 
386
394
  // D4 (M21/M23) source layer: every environment/argument read in app source is registered in the app's switches.json and
387
395
  // every S1-S4 selector is compiled out of release. The inventory is `config.releaseSwitches.inventory` (relative to the
@@ -415,7 +423,8 @@ try {
415
423
  console.log(`right-release: dry run: ${error.message}`);
416
424
  }
417
425
  // remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
418
- const closureInput = { treeText, devtools: devtoolsAllowance, remoteHost: config.releaseSwitches?.remoteHost, commands: closureCommands.map((c) => commandTokens(c)) };
426
+ // remoteLoopback: the daemon `remote-loopback` feature is a separate declaration (releaseSwitches.remoteLoopback: true); remoteHost does not cover it.
427
+ const closureInput = { treeText, devtools: devtoolsAllowance, remoteHost: config.releaseSwitches?.remoteHost, remoteLoopback: config.releaseSwitches?.remoteLoopback, commands: closureCommands.map((c) => commandTokens(c)) };
419
428
  if (releaseSwitchInventoryText != null) {
420
429
  // iOS: only what the iPhone app compiles — the Xcode project and its local packages, plus the local crates in the
421
430
  // resolved aarch64-apple-ios cargo graph (feature-aware, unlike the ownership closure). Other targets walk the app root.
@@ -979,8 +988,8 @@ function sweepStaleRustArtifacts() {
979
988
  }
980
989
 
981
990
  async function runInstall(pm, cwd) {
982
- if (pm === "pnpm") await run("pnpm", ["install", "--frozen-lockfile"], cwd);
983
- else if (pm === "npm") await run("npm", ["ci"], cwd);
991
+ if (pm === "pnpm") await run("pnpm", ["install", "--frozen-lockfile"], cwd, {}, { install: true });
992
+ else if (pm === "npm") await run("npm", ["ci"], cwd, {}, { install: true });
984
993
  else fail(`unsupported package manager: ${pm}`);
985
994
  }
986
995
 
@@ -1772,7 +1781,8 @@ function run(cmd, runArgs, cwd, env = {}, options = {}) {
1772
1781
  const useShell = process.platform === "win32" && !/\.(?:exe|com)$/i.test(cmd);
1773
1782
  const child = spawn(cmd, runArgs, {
1774
1783
  cwd,
1775
- env: macosCodesignEnvironment({ ...process.env, ...releaseEnv, ...env }),
1784
+ // Build children resolve in production mode; package installs keep the caller's NODE_ENV (production drops devDependencies).
1785
+ env: macosCodesignEnvironment(options.install ? { ...process.env, ...releaseEnv, ...env } : productionBuildEnv({ ...process.env, ...releaseEnv, ...env })),
1776
1786
  stdio: options.captureStdout ? ["inherit", "pipe", "inherit"] : "inherit",
1777
1787
  shell: useShell,
1778
1788
  windowsHide: true,
package/rightkit-sync.mjs CHANGED
@@ -192,6 +192,14 @@ export function stageSourceVersions(entries, { file = VERSIONS_FILE, root = righ
192
192
  return { changes, copies, written };
193
193
  }
194
194
 
195
+ /** Feed file and consumer-copy root of one requested workspace. recordPublished/stageSourceVersions default to the
196
+ * location of THIS module, which is inside the package store when right-release runs from an installed
197
+ * @rightkit/release; every caller that knows its workspace passes these explicitly. */
198
+ export function workspaceFeedOptions(workspaceRoot) {
199
+ const root = path.resolve(workspaceRoot);
200
+ return { root, file: path.join(root, "packages", "release", "rightkit-versions.json") };
201
+ }
202
+
195
203
  /** Record one just-published version (the registry index can lag the upload) and refresh the copies.
196
204
  * Called by right-release after a verified cargo or npm publish; never fails the publish itself unless `strict` is set. */
197
205
  export function recordPublished({ registry, name, version }, { file = VERSIONS_FILE, root = rightkitRoot, log = (line) => process.stdout.write(`${line}\n`), strict = false } = {}) {