@rightkit/release 0.6.4 → 0.6.6

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.
Files changed (48) hide show
  1. package/README.md +109 -7
  2. package/admission-files.mjs +279 -0
  3. package/admission-host.mjs +403 -0
  4. package/admission.mjs +146 -0
  5. package/android-play-upload.mjs +4 -4
  6. package/app-check.mjs +1326 -0
  7. package/apple-profile-create.mjs +4 -3
  8. package/apple-profile-renew.mjs +5 -4
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/rightkit-gate.mjs +495 -69
  11. package/binary-license-notices.mjs +2 -1
  12. package/build-release.mjs +87 -26
  13. package/cache-command.mjs +4 -3
  14. package/cargo-license-notices.mjs +2 -1
  15. package/cargo-manifest-text.mjs +25 -0
  16. package/cargo-profile-contract.mjs +26 -13
  17. package/cli/right-release.mjs +45 -5
  18. package/command-spec.mjs +4 -5
  19. package/deps.mjs +5 -4
  20. package/deterministic-scans.mjs +301 -37
  21. package/development.mjs +5 -5
  22. package/github-release.mjs +5 -3
  23. package/github-upload-gate.mjs +6 -6
  24. package/init-command.mjs +134 -8
  25. package/inventory-check.mjs +12 -1
  26. package/ios-upload.mjs +4 -4
  27. package/mirror-root-artifact.mjs +3 -4
  28. package/npm-license-notices.mjs +2 -1
  29. package/outdated-command.mjs +91 -0
  30. package/ownership-dependency-allowlist.json +2 -0
  31. package/package.json +41 -3
  32. package/pipeline-admission.mjs +12 -0
  33. package/prebuild-gates.mjs +7 -3
  34. package/publish-cargo.mjs +215 -3
  35. package/publish-update.mjs +5 -3
  36. package/release-config.mjs +36 -5
  37. package/release-dependencies-command.mjs +4 -3
  38. package/release-qa-audit.mjs +12 -10
  39. package/release-qa-command.mjs +42 -7
  40. package/release-qa-hardening.mjs +10 -3
  41. package/release-qa-policy.mjs +214 -47
  42. package/release.mjs +117 -62
  43. package/rightkit-registry-resolver.mjs +5 -2
  44. package/rightkit-versions.json +18 -13
  45. package/stage-sidecar-command.mjs +5 -5
  46. package/test-command.mjs +3 -2
  47. package/upload-release.mjs +3 -2
  48. package/web-build.mjs +2 -1
package/init-command.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  // (bogusyogi/rightkit#385): renames the desktop app template into a real app, in place. Ports PostRight's
4
4
  // crates/tools/src/init.rs. The template identity (`RightApp`, `rightapp`, `RIGHTAPP`, `app.rightsuite.rightapp`)
5
5
  // is rewritten in text files; nothing else is touched and no gate or build is run.
6
- import { readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
7
8
  import path from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
 
@@ -50,6 +51,60 @@ export function resolveIdentity({ name, id, slug }) {
50
51
  return { name, id, slug: resolvedSlug, warnings };
51
52
  }
52
53
 
54
+ // Cargo writes Cargo.lock with its [[package]] tables sorted by name (then version) and each `dependencies` list sorted by name, and
55
+ // `cargo --locked` refuses a lock that differs from what it would write. Renaming the template's own crates moves them in that order,
56
+ // so the lock is re-sorted after the text rename; without it every `--locked` script fails on the first build of the new app.
57
+ const LOCK_FILE = "Cargo.lock";
58
+ const byteOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
59
+ const semverParts = (version) => {
60
+ const [core, pre] = String(version ?? "").split("+")[0].split(/-(.*)/s);
61
+ return { nums: core.split(".").map((part) => Number.parseInt(part, 10) || 0), pre: pre ?? null };
62
+ };
63
+ function compareSemver(a, b) {
64
+ const x = semverParts(a);
65
+ const y = semverParts(b);
66
+ for (let i = 0; i < 3; i += 1) if (x.nums[i] !== y.nums[i]) return (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
67
+ if (x.pre === y.pre) return 0;
68
+ if (x.pre === null) return 1;
69
+ if (y.pre === null) return -1;
70
+ return byteOrder(x.pre, y.pre);
71
+ }
72
+ const lockField = (block, key) => new RegExp(`^${key} = "([^"]*)"$`, "m").exec(block)?.[1] ?? "";
73
+ /** `"name"`, `"name version"` or `"name version (source)"`: the name and version of one `dependencies` entry. */
74
+ const lockDependency = (line) => {
75
+ const [name = "", version = ""] = line.trim().replace(/^"|",?$/g, "").split(" ");
76
+ return { name, version };
77
+ };
78
+
79
+ /** Sort one lock's [[package]] tables and their `dependencies` lists the way cargo writes them; everything else is kept byte for byte. */
80
+ export function sortCargoLock(text) {
81
+ const crlf = text.includes("\r\n");
82
+ const lf = crlf ? text.replace(/\r\n/g, "\n") : text;
83
+ const first = /^\[\[package\]\]$/m.exec(lf);
84
+ if (!first) return text;
85
+ const head = lf.slice(0, first.index);
86
+ let body = lf.slice(first.index);
87
+ let tail = "";
88
+ const other = /^\[(?!\[package\]\]$)/m.exec(body);
89
+ if (other) {
90
+ tail = body.slice(other.index);
91
+ body = body.slice(0, other.index);
92
+ }
93
+ const blocks = body.replace(/\n+$/, "").split(/\n\n(?=\[\[package\]\]$)/m).map((block) => block.replace(
94
+ /^dependencies = \[\n([\s\S]*?)\n\]$/m,
95
+ (_, list) => `dependencies = [\n${list.split("\n").map((line) => ({ line, ...lockDependency(line) }))
96
+ .sort((a, b) => byteOrder(a.name, b.name) || compareSemver(a.version, b.version)).map((entry) => entry.line).join("\n")}\n]`,
97
+ ));
98
+ blocks.sort((a, b) => byteOrder(lockField(a, "name"), lockField(b, "name")) || compareSemver(lockField(a, "version"), lockField(b, "version")));
99
+ const out = `${head}${blocks.join("\n\n")}\n${tail ? `\n${tail}` : ""}`;
100
+ return crlf ? out.replace(/\n/g, "\r\n") : out;
101
+ }
102
+
103
+ /** Rename the template identity in a Cargo.lock and restore cargo's order, so `cargo --locked` accepts the renamed lock. */
104
+ export function reidentifyCargoLock(text, identity) {
105
+ return sortCargoLock(renameText(text, identity));
106
+ }
107
+
53
108
  export function walkTextFiles(dir, out = []) {
54
109
  let entries;
55
110
  try {
@@ -61,26 +116,66 @@ export function walkTextFiles(dir, out = []) {
61
116
  const full = path.join(dir, entry.name);
62
117
  if (entry.isDirectory()) {
63
118
  if (!SKIP_DIRS.has(entry.name)) walkTextFiles(full, out);
64
- } else if (entry.isFile() && TEXT_EXT.has(path.extname(entry.name).slice(1))) {
119
+ } else if (entry.isFile() && (TEXT_EXT.has(path.extname(entry.name).slice(1)) || entry.name === LOCK_FILE)) {
65
120
  out.push(full);
66
121
  }
67
122
  }
68
123
  return out;
69
124
  }
70
125
 
126
+ // `rightapps` is not the template identity: it is the RightApps distribution service (the `r2-rightapps` provider), which every app
127
+ // keeps. A spelling followed by a whole-word `s` is therefore left alone; `rightapp-engine`, `rightapp_protocol`, `RIGHTAPP_HOME` and
128
+ // the rest of the identity still rename.
129
+ const spelling = (word) => new RegExp(`${word}(?![sS](?![A-Za-z0-9]))`, "g");
130
+ const UPPER_WORD = spelling(UPPER);
131
+ const TITLE_WORD = spelling(TITLE);
132
+ const LOWER_WORD = spelling(LOWER);
133
+
71
134
  /** Rewrite the template identity in one string; the bundle id first, then the three spellings. */
72
135
  export function renameText(text, { name, id, slug }) {
73
136
  return text
74
137
  .replaceAll(TEMPLATE_ID, id)
75
- .replaceAll(UPPER, slug.toUpperCase())
76
- .replaceAll(TITLE, name)
77
- .replaceAll(LOWER, slug);
138
+ .replace(UPPER_WORD, () => slug.toUpperCase())
139
+ .replace(TITLE_WORD, () => name)
140
+ .replace(LOWER_WORD, () => slug);
141
+ }
142
+
143
+ export const LEGAL_MANIFEST = "legal/legal-manifest.json";
144
+ const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
145
+
146
+ /**
147
+ * Recompute the `sha256` of every document and appendix of a legal manifest from the bytes `read(relativePath)` returns (paths are
148
+ * relative to the manifest's directory; `read` returns Buffer, string or null for a missing file). Returns `{ text, changed, stale,
149
+ * material }`: the manifest text with the new digests (2-space JSON, as `legal-snapshot` writes it), the entries whose digest moved,
150
+ * and which of those are material agreements. Material documents feed the acceptance digest in src/generated/legal-contract.json, which
151
+ * only `legal-snapshot` can recompute, so the caller warns about them.
152
+ */
153
+ export function rehashLegalManifest(text, read) {
154
+ const manifest = JSON.parse(text);
155
+ const changed = [];
156
+ const material = [];
157
+ for (const entry of [...(manifest.documents ?? []), ...(manifest.appendices ?? [])]) {
158
+ const bytes = typeof entry?.path === "string" ? read(entry.path) : null;
159
+ if (bytes === null || bytes === undefined) continue;
160
+ const digest = sha256Hex(typeof bytes === "string" ? Buffer.from(bytes) : bytes);
161
+ if (entry.sha256 === digest) continue;
162
+ entry.sha256 = digest;
163
+ changed.push(entry.path);
164
+ if (entry.material === true) material.push(entry.path);
165
+ }
166
+ return { text: changed.length ? `${JSON.stringify(manifest, null, 2)}\n` : text, changed, material };
78
167
  }
79
168
 
80
- /** Rename the template under `root`. Returns the identity and the changed file paths (relative, sorted). */
169
+ /**
170
+ * Rename the template under `root`. Returns the identity, the changed file paths (relative, sorted) and `warnings`.
171
+ * The rename changes the bytes of text the legal manifest hashes (the privacy notice and the npm appendix carry the product name), so the
172
+ * manifest's digests are recomputed from the renamed bytes in the same pass.
173
+ */
81
174
  export function initTemplate(root, options) {
82
175
  const identity = resolveIdentity(options);
83
176
  const changed = [];
177
+ const warnings = [...identity.warnings];
178
+ const renamed = new Map();
84
179
  for (const file of walkTextFiles(root)) {
85
180
  if (path.basename(file) === SELF) continue;
86
181
  let before;
@@ -89,20 +184,51 @@ export function initTemplate(root, options) {
89
184
  } catch {
90
185
  continue;
91
186
  }
92
- const after = renameText(before, identity);
187
+ const after = path.basename(file) === LOCK_FILE ? reidentifyCargoLock(before, identity) : renameText(before, identity);
93
188
  if (after === before) continue;
189
+ renamed.set(path.resolve(file), after);
94
190
  if (!options.dryRun) writeFileSync(file, after);
95
191
  changed.push(path.relative(root, file).split(path.sep).join("/"));
96
192
  }
97
- return { ...identity, changed: changed.sort() };
193
+ const manifestFile = path.join(root, ...LEGAL_MANIFEST.split("/"));
194
+ if (existsSync(manifestFile)) {
195
+ const legalDir = path.dirname(manifestFile);
196
+ const read = (relative) => {
197
+ const target = path.resolve(legalDir, relative);
198
+ if (renamed.has(target)) return renamed.get(target);
199
+ try { return readFileSync(target); } catch { return null; }
200
+ };
201
+ const rehashed = rehashLegalManifest(renamed.get(path.resolve(manifestFile)) ?? readFileSync(manifestFile, "utf8"), read);
202
+ if (rehashed.changed.length) {
203
+ if (!options.dryRun) writeFileSync(manifestFile, rehashed.text);
204
+ if (!changed.includes(LEGAL_MANIFEST)) changed.push(LEGAL_MANIFEST);
205
+ }
206
+ for (const material of rehashed.material) {
207
+ warnings.push(`the rename changed the material legal document legal/${material}, which feeds src/generated/legal-contract.json; run \`pnpm exec legal-snapshot\` to regenerate the acceptance contract`);
208
+ }
209
+ }
210
+ return { ...identity, changed: changed.sort(), warnings };
98
211
  }
99
212
 
213
+ /** What a person does after init: the steps no command can do for them, in order. */
214
+ export const NEXT_STEPS = Object.freeze([
215
+ "pnpm install (installs the pinned packages and, through `prepare`, the pre-push hook)",
216
+ "right-release doctor (the build's own checks for this app, as rows)",
217
+ "set the app's public legal host in legal-snapshot.config.json, replace legal/PRODUCT_SCHEDULE.md and legal/PRIVACY_NOTICE.md with reviewed text, then `pnpm exec legal-snapshot`",
218
+ "register the repository on each host: add it to packages/release/ownership-repositories.json in RightKit (and its byte copy under packages/build-control-policy), then run `right-release ownership trust-refresh` on the Mac and the Dell (a person with administrator rights; an agent cannot)",
219
+ "README.md in the template: \"Create a new app (checklist)\"",
220
+ ]);
221
+
100
222
  export function runInitCommand(argv, io = { stdout: console.log, stderr: console.error }) {
101
223
  const opts = parseInitArgs(argv);
102
224
  const result = initTemplate(path.resolve(opts.dir), opts);
103
225
  for (const warning of result.warnings) io.stderr(`warning: ${warning}`);
104
226
  io.stdout(`right-release init${opts.dryRun ? " --dry-run" : ""}: ${result.changed.length} file(s) ${opts.dryRun ? "would change" : "changed"}`);
105
227
  for (const file of result.changed) io.stdout(` ${file}`);
228
+ if (!opts.dryRun) {
229
+ io.stdout("next:");
230
+ for (const step of NEXT_STEPS) io.stdout(` ${step}`);
231
+ }
106
232
  return result;
107
233
  }
108
234
 
@@ -16,6 +16,7 @@ import {
16
16
  generateAdoption,
17
17
  generateLocal,
18
18
  } from "../../scripts/generate-index.mjs";
19
+ import { PORT_MATRIX_OUTPUTS, generatePortMatrix } from "../../scripts/generate-port-matrix.mjs";
19
20
 
20
21
  const HERE = dirname(fileURLToPath(import.meta.url));
21
22
  export const DEFAULT_REPO_ROOT = resolve(HERE, "..", "..");
@@ -781,6 +782,16 @@ function checkGenerated({ repoRoot, appsRoot, apps, findings }) {
781
782
  for (const rel of driftOf(repoRoot, adoption)) {
782
783
  findings.push(issue("generated", rel, "generated-doc-drift", "committed adoption report differs from the app repos; run generate-index.mjs"));
783
784
  }
785
+ // The port matrix reads the app repos like the adoption report. Its stamp, app directories and HEADs, and per-app check counts
786
+ // are carried over from the committed docs/port-matrix.json, so only a change in the data itself reads as drift.
787
+ try {
788
+ const matrix = generatePortMatrix({ root: repoRoot, workspace: appsRoot, carry: true });
789
+ for (const rel of driftOf(repoRoot, matrix.outputs)) {
790
+ findings.push(issue("generated", rel, "generated-doc-drift", "committed port matrix differs from the app repos; run generate-index.mjs"));
791
+ }
792
+ } catch (error) {
793
+ findings.push(issue("generated", PORT_MATRIX_OUTPUTS[2], "generated-doc-drift", `the port matrix could not be regenerated: ${error.message}`));
794
+ }
784
795
  return { local, adoption: Object.values(adoption)[0] };
785
796
  }
786
797
 
@@ -842,7 +853,7 @@ export async function runInventoryCheck(options = {}) {
842
853
  account: { crates: account.crates, npm: account.npm },
843
854
  thirdParty: { declarations: thirdParty.declarations, uniquePackages: thirdParty.uniquePackages, lockDuplicates: thirdParty.lockDuplicates },
844
855
  apps: { rows: appRows },
845
- generated: { checked: ["INDEX.md", "docs/CAPABILITY-MAP.md", "docs/ADOPTION.md"] },
856
+ generated: { checked: ["INDEX.md", "docs/CAPABILITY-MAP.md", "docs/ADOPTION.md", ...PORT_MATRIX_OUTPUTS] },
846
857
  hygiene: { checked: records.length },
847
858
  },
848
859
  };
package/ios-upload.mjs CHANGED
@@ -21,8 +21,8 @@
21
21
  // TestFlight steps use the same in-memory ES256 JWT. No file, temp or not, ever holds the key; no altool, no Transporter.
22
22
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
23
23
  import path from "node:path";
24
- import { pathToFileURL } from "node:url";
25
24
  import { spawnSync } from "./background-process.mjs";
25
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
26
26
  import { assertGuiSigningSession, assertIosHost, IosHostError } from "./ios-host.mjs";
27
27
  import { acquireFileLock } from "./ios-lock.mjs";
28
28
  import {
@@ -136,7 +136,7 @@ const git = (cwd, args) => {
136
136
  };
137
137
 
138
138
  /** Local checks only; no network, no secret. The sealed release is the only input: build-free by construction. */
139
- export async function authorizeIosUpload({ releaseId, configName = "right-release.config.mjs", cwd = process.cwd() }) {
139
+ export async function authorizeIosUpload({ releaseId, cwd = process.cwd(), configName = defaultReleaseConfigFile(cwd) }) {
140
140
  if (!RELEASE_ID.test(releaseId ?? "")) refuse("IOS_UPLOAD_RELEASE_REQUIRED", "--release <sealed-id> is required");
141
141
  const repoRoot = git(cwd, ["rev-parse", "--show-toplevel"]);
142
142
  assertPrimaryReleaseCheckout(repoRoot);
@@ -144,8 +144,8 @@ export async function authorizeIosUpload({ releaseId, configName = "right-releas
144
144
  assertCleanSource({ status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]), commandId: "right-release upload" });
145
145
  const configPath = path.resolve(cwd, configName);
146
146
  if (!existsSync(configPath)) refuse("IOS_UPLOAD_CONFIG_INVALID", `release config is required: ${configName}`);
147
- // The config is a module that runs on import: loaded only after the tree is proven clean.
148
- const config = (await import(`${pathToFileURL(configPath).href}?upload=${Date.now()}`)).default;
147
+ // A legacy config is a module that runs on import (a JSON config is data): loaded only after the tree is proven clean.
148
+ const config = await importReleaseConfig(configPath, { query: `upload=${Date.now()}` });
149
149
  const distribution = validateIosDistribution(targetDistribution(config, "ios"));
150
150
  const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, "ios");
151
151
  const appRoot = path.dirname(configPath);
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Two forms:
3
3
  // right-release mirror-root-artifact --file <artifact> --package-root <dir> [--name <filename>]
4
- // right-release mirror-root-artifact --config <right-release.config.mjs> --platform mac|win [--package-root <dir>]
4
+ // right-release mirror-root-artifact --config <right-release.config.json> --platform mac|win [--package-root <dir>]
5
5
  // The config form mirrors every entry of `targets.<platform>.mirror`, a list of `"<app-relative file>"` or
6
6
  // `{ file, name?, pruneSuffix? }`. `pruneSuffix` (for example "-setup.exe") then removes the other files with that suffix
7
7
  // from the managed directory, so it holds exactly the current artifact. Run it as `targets.<platform>.postSign`.
@@ -10,7 +10,7 @@ import { configuredManagedRootSpellings } from "./artifact-storage.mjs";
10
10
  import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
11
11
  import { execFileSync } from "./background-process.mjs";
12
12
  import path from "node:path";
13
- import { pathToFileURL } from "node:url";
13
+ import { importReleaseConfig } from "./release-config.mjs";
14
14
 
15
15
  const args = parseArgs(process.argv.slice(2));
16
16
  const usage = "Usage: right-release mirror-root-artifact (--file <artifact> --package-root <dir> [--name <filename>] | --config <file> --platform mac|win [--package-root <dir>])";
@@ -31,8 +31,7 @@ let entries;
31
31
  let rootArg = args.packageRoot;
32
32
  if (args.config) {
33
33
  const configPath = path.resolve(args.config);
34
- const loaded = await import(pathToFileURL(configPath).href);
35
- const config = loaded.default ?? loaded.config ?? loaded;
34
+ const config = await importReleaseConfig(configPath, { lenient: true });
36
35
  const declared = config?.targets?.[args.platform]?.mirror;
37
36
  if (!Array.isArray(declared) || declared.length === 0) {
38
37
  console.error(`right-release mirror-root-artifact: targets.${args.platform}.mirror declares no files in ${args.config}`);
@@ -16,6 +16,7 @@ import path from "node:path";
16
16
  import { pathToFileURL } from "node:url";
17
17
  import { spawnSync } from "./background-process.mjs";
18
18
  import { CommandSpecError, loadAppConfig, relativeInside } from "./command-spec.mjs";
19
+ import { defaultReleaseConfigFile } from "./release-config.mjs";
19
20
 
20
21
  const fail = (message) => { throw new CommandSpecError(message); };
21
22
 
@@ -119,7 +120,7 @@ export function generateNpmNotices({ appRoot, config, flags = {}, check = false,
119
120
  }
120
121
 
121
122
  export function parseNoticeArgs(argv) {
122
- const opts = { config: "right-release.config.mjs", output: undefined, productName: undefined, check: false };
123
+ const opts = { config: defaultReleaseConfigFile(), output: undefined, productName: undefined, check: false };
123
124
  const rest = argv[0] === "npm" ? argv.slice(1) : argv;
124
125
  if (argv[0] !== "npm") throw new Error("expected: license-notices npm");
125
126
  for (let i = 0; i < rest.length; i += 1) {
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ // `right-release update <args>`: run the @rightkit/outdated updater (its `right-update` bin) that this package pins.
3
+ //
4
+ // Why a command and not only the dependency: pnpm links just the bins of a project's direct dependencies into
5
+ // node_modules/.bin, so an app that depends on @rightkit/release alone has no `right-update` on its path even though
6
+ // @rightkit/outdated is installed beneath release. This module finds the updater installed for @rightkit/release and
7
+ // runs its bin, so `pnpm exec right-release update run` works in every app that can run `right-release`.
8
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { spawn } from "./background-process.mjs";
12
+
13
+ export const OUTDATED_PACKAGE = "@rightkit/outdated";
14
+ export const OUTDATED_BIN = "right-update";
15
+
16
+ /** The exact command a refusal gives an app that must move a stale RightKit pin. */
17
+ export const UPDATE_COMMAND = "pnpm exec right-release update run";
18
+
19
+ export class OutdatedUnavailableError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "OutdatedUnavailableError";
23
+ this.code = "OUTDATED_UNAVAILABLE";
24
+ }
25
+ }
26
+
27
+ const here = () => path.dirname(fileURLToPath(import.meta.url));
28
+
29
+ /**
30
+ * Directory of the @rightkit/outdated installed for the package at `fromDir`, or null. This is Node's lookup
31
+ * (each ancestor's node_modules, never one named node_modules itself) done on the filesystem, because
32
+ * @rightkit/outdated exports only "." under an `import` condition and a caller must not depend on that shape.
33
+ */
34
+ export function findOutdatedPackage(fromDir = here()) {
35
+ let directory = realpathSync(fromDir);
36
+ for (;;) {
37
+ if (path.basename(directory) !== "node_modules") {
38
+ const candidate = path.join(directory, "node_modules", ...OUTDATED_PACKAGE.split("/"));
39
+ if (existsSync(path.join(candidate, "package.json"))) return realpathSync(candidate);
40
+ }
41
+ const parent = path.dirname(directory);
42
+ if (parent === directory) return null;
43
+ directory = parent;
44
+ }
45
+ }
46
+
47
+ /** Absolute path of the `right-update` script of the @rightkit/outdated installed for the package at `fromDir`. */
48
+ export function resolveOutdatedBin(fromDir = here()) {
49
+ const root = findOutdatedPackage(fromDir);
50
+ if (root === null) {
51
+ throw new OutdatedUnavailableError(`${OUTDATED_PACKAGE} is not installed for @rightkit/release; reinstall @rightkit/release so its pinned dependency is present`);
52
+ }
53
+ let manifest;
54
+ try {
55
+ manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
56
+ } catch {
57
+ throw new OutdatedUnavailableError(`${OUTDATED_PACKAGE} has an unreadable package.json at ${root}`);
58
+ }
59
+ const named = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[OUTDATED_BIN];
60
+ if (manifest.name !== OUTDATED_PACKAGE || typeof named !== "string" || named === "") {
61
+ throw new OutdatedUnavailableError(`${root} is not an ${OUTDATED_PACKAGE} that provides ${OUTDATED_BIN}`);
62
+ }
63
+ const file = path.resolve(root, named);
64
+ const relative = path.relative(root, file);
65
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) || !existsSync(file)) {
66
+ throw new OutdatedUnavailableError(`${OUTDATED_PACKAGE} ${manifest.version ?? ""} lists ${OUTDATED_BIN} at ${named}, which is not installed`);
67
+ }
68
+ return file;
69
+ }
70
+
71
+ /** Run the updater with `argv` and resolve to its exit status. */
72
+ export function runOutdated(argv, { fromDir = here(), cwd = process.cwd(), env = process.env, stdio = "inherit" } = {}) {
73
+ const bin = resolveOutdatedBin(fromDir);
74
+ return new Promise((resolve, reject) => {
75
+ const child = spawn(process.execPath, [bin, ...argv], { cwd, env, stdio, windowsHide: true });
76
+ child.on("error", reject);
77
+ child.on("exit", (code) => resolve(code ?? 1));
78
+ });
79
+ }
80
+
81
+ export async function main(argv = process.argv.slice(2)) {
82
+ try {
83
+ process.exitCode = await runOutdated(argv);
84
+ } catch (error) {
85
+ console.error(`right-release update: ${error.message}`);
86
+ process.exitCode = error instanceof OutdatedUnavailableError ? 2 : 1;
87
+ }
88
+ }
89
+
90
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
91
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) await main();
@@ -399,10 +399,12 @@
399
399
  "cargo:base64",
400
400
  "cargo:block2",
401
401
  "cargo:chrono",
402
+ "cargo:clap",
402
403
  "cargo:dirs",
403
404
  "cargo:futures",
404
405
  "cargo:getrandom",
405
406
  "cargo:image",
407
+ "cargo:libc",
406
408
  "cargo:objc2",
407
409
  "cargo:objc2-foundation",
408
410
  "cargo:reqwest",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -8,8 +8,43 @@
8
8
  "right-release": "cli/right-release.mjs",
9
9
  "rightkit-gate": "bin/rightkit-gate.mjs"
10
10
  },
11
+ "exports": {
12
+ "./bin/rightkit-gate.mjs": "./bin/rightkit-gate.mjs",
13
+ "./cargo-contract.mjs": "./cargo-contract.mjs",
14
+ "./cargo-target.mjs": "./cargo-target.mjs",
15
+ "./cli/right-release.mjs": "./cli/right-release.mjs",
16
+ "./development.mjs": "./development.mjs",
17
+ "./direct-bootstrap.mjs": "./direct-bootstrap.mjs",
18
+ "./disk-policy.mjs": "./disk-policy.mjs",
19
+ "./dmg-build.mjs": "./dmg-build.mjs",
20
+ "./engine-locator.mjs": "./engine-locator.mjs",
21
+ "./github-release.mjs": "./github-release.mjs",
22
+ "./legal-contract.mjs": "./legal-contract.mjs",
23
+ "./macos-file-evidence.mjs": "./macos-file-evidence.mjs",
24
+ "./macos-notarization.mjs": "./macos-notarization.mjs",
25
+ "./macos-signing-identity.mjs": "./macos-signing-identity.mjs",
26
+ "./macos-signing-session.mjs": "./macos-signing-session.mjs",
27
+ "./managed-cargo.mjs": "./managed-cargo.mjs",
28
+ "./native-release-finalization.mjs": "./native-release-finalization.mjs",
29
+ "./notary-auth.mjs": "./notary-auth.mjs",
30
+ "./package.json": "./package.json",
31
+ "./packaged-app-identity.mjs": "./packaged-app-identity.mjs",
32
+ "./pe-content.mjs": "./pe-content.mjs",
33
+ "./publication-gate.mjs": "./publication-gate.mjs",
34
+ "./qa-build-features.mjs": "./qa-build-features.mjs",
35
+ "./release-dependencies.mjs": "./release-dependencies.mjs",
36
+ "./sign-windows.mjs": "./sign-windows.mjs",
37
+ "./signing-agent-client.mjs": "./signing-agent-client.mjs",
38
+ "./stage-binary.mjs": "./stage-binary.mjs",
39
+ "./stage-sidecar.mjs": "./stage-sidecar.mjs",
40
+ "./supply-chain-evidence.mjs": "./supply-chain-evidence.mjs",
41
+ "./tauri-bundle-marker.mjs": "./tauri-bundle-marker.mjs"
42
+ },
11
43
  "rightkit": {
12
44
  "publishCheck": {
45
+ "skipImport": [
46
+ "./sign-windows.mjs"
47
+ ],
13
48
  "binSmoke": {
14
49
  "right-release": [
15
50
  "--version"
@@ -33,7 +68,9 @@
33
68
  "LICENSE-MIT",
34
69
  "LICENSE-APACHE",
35
70
  "!*.test.mjs",
36
- "!standalone-clone-evidence.json"
71
+ "!cli/*.test.mjs",
72
+ "!standalone-clone-evidence.json",
73
+ "!refusals.json"
37
74
  ],
38
75
  "sideEffects": false,
39
76
  "publishConfig": {
@@ -46,10 +83,11 @@
46
83
  "directory": "packages/release"
47
84
  },
48
85
  "dependencies": {
86
+ "@rightkit/outdated": "0.2.0",
49
87
  "smol-toml": "1.9.1"
50
88
  },
51
89
  "scripts": {
52
- "test": "node --import ./test-support/broker-guard.mjs --test --test-concurrency=1 --test-force-exit *.test.mjs",
90
+ "test": "node --import ./test-support/broker-guard.mjs --test --test-concurrency=1 --test-force-exit *.test.mjs cli/*.test.mjs",
53
91
  "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
54
92
  "test:signer:windows": "node sign-release-manifest.integration.mjs",
55
93
  "doctor:all": "node --test right-suite-contract.test.mjs",
@@ -230,6 +230,7 @@ function admitRuntime(bin, rest, options, label) {
230
230
  case "right-release": return admitRightRelease(rest, label);
231
231
  case "tauri": return admitTauri(rest, options, label);
232
232
  case "vite": return rest[0] === "build" ? (VITE_OPTIONS(rest.slice(1), label) ?? admit("vite-build", "runtime")) : rest.length === 1 && rest[0] === "--version" ? admit("vite-version", "runtime") : refuse("R1-option", `${label}: only vite build and vite --version are admitted`);
233
+ case "rightkit-gate": return admitRightKitGate(rest, label);
233
234
  case "tsc": return shellSafeArgs(rest, label) ?? admit("tsc", "runtime");
234
235
  case "vitest": return rest[0] === "run" || (rest.length === 1 && rest[0] === "--version") ? (shellSafeArgs(rest, label) ?? admit("vitest", "runtime")) : refuse("R1-option", `${label}: only vitest run is admitted`);
235
236
  case "eslint": return shellSafeArgs(rest, label) ?? admit("eslint", "runtime");
@@ -237,6 +238,16 @@ function admitRuntime(bin, rest, options, label) {
237
238
  }
238
239
  }
239
240
 
241
+ /**
242
+ * `rightkit-gate install-hook`: writes the committed pre-push hook and sets core.hooksPath, so an app's `prepare` script may run it.
243
+ * It builds, signs, packages and uploads nothing. The gate itself (`rightkit-gate`, `--base`) compiles and is run by the hook, never
244
+ * by a release config; only the installer and the version probe are admitted.
245
+ */
246
+ function admitRightKitGate(args, label) {
247
+ if (args.length === 1 && (args[0] === "install-hook" || args[0] === "--version")) return admit(`rightkit-gate-${args[0].replace(/^--/, "")}`, "owner");
248
+ return refuse("R1-owner-subcommand", `${label}: only rightkit-gate install-hook (and --version) is an admitted pipeline command; the gate itself runs from the pre-push hook`);
249
+ }
250
+
240
251
  function admitRightRelease(args, label) {
241
252
  const sub = args[0];
242
253
  if (!OWNER_SUBCOMMANDS.has(sub)) return refuse("R1-owner-subcommand", `${label}: right-release ${JSON.stringify(sub ?? "")} is not a known RightKit command`);
@@ -350,6 +361,7 @@ export function admitInvocation(step, options = {}) {
350
361
  const tool = classifyTokens([name, ...args]);
351
362
  if (tool) return refuse("R3-pipeline-tool", `${label}: ${tool.tool} is a ${tool.stage} tool; build, sign, notarize, package and upload run only inside RightKit`);
352
363
  if (name === "right-release") return admitRightRelease(args, label);
364
+ if (name === "rightkit-gate") return admitRightKitGate(args, label);
353
365
  if (name === "pnpm") return admitPnpm(args, inner, label, chain);
354
366
  if (name === "node") return admitNode(args, inner, label);
355
367
  if (name === "rightkit") return refuse("R1-retired-client", `${label}: rightkit is the retired name of the RustRight client (rustright#91); use rustright ${args.slice(0, 2).join(" ")}`);
@@ -1,6 +1,6 @@
1
1
  // Source-level release gates that run before a build starts. The build (release.mjs) and `doctor --gates` call the
2
2
  // same function, so the doctor can never pass a config the build refuses. Gate order is part of the contract.
3
- import { existsSync, readFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { spawnSync } from "./background-process.mjs";
6
6
  import { assertReleaseDependencies } from "./release-dependencies.mjs";
@@ -145,11 +145,15 @@ export function evaluatePrebuildGates({ root, config, target, platform, iosSpec,
145
145
  let sourceRoots;
146
146
  let skipRoots;
147
147
  let repoTop;
148
+ let appRootReal;
149
+ const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
148
150
  if (releaseSwitchInventoryText != null) {
149
151
  gate("source-switch-roots", ["closure-commands", ...(strict ? ["feature-closure"] : [])], () => {
150
152
  if (platform === "ios") {
151
153
  repoTop = git(root, ["rev-parse", "--show-toplevel"]);
152
- const swiftSide = (iosOwnershipScope({ repoRoot: repoTop, appRoot: root, target: config.targets?.ios }) ?? []).filter((dir) => dir !== ".rightkit").map((dir) => path.join(repoTop, dir));
154
+ // Git reports the canonical path; a caller's root may be a symlink or a Windows short name (hosted temp roots).
155
+ appRootReal = nativeReal(root);
156
+ const swiftSide = (iosOwnershipScope({ repoRoot: repoTop, appRoot: appRootReal, target: config.targets?.ios }) ?? []).filter((dir) => dir !== ".rightkit").map((dir) => path.join(repoTop, dir));
153
157
  sourceRoots = [...new Set([...swiftSide, ...localCrateDirsFromTree(treeText, repoTop)])].sort();
154
158
  console.log(`right-release: source switch scan (ios): ${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}`);
155
159
  } else {
@@ -162,7 +166,7 @@ export function evaluatePrebuildGates({ root, config, target, platform, iosSpec,
162
166
  if (platform === "ios") {
163
167
  gate("source-switch-prepare", ["source-switch-roots"], () => {
164
168
  for (const step of config.targets?.ios?.iosPackage?.prepare ?? []) {
165
- const cwd = path.resolve(root, step?.cwd ?? ".");
169
+ const cwd = path.resolve(appRootReal, step?.cwd ?? ".");
166
170
  if (!sourceRoots.some((dir) => cwd === dir || cwd.startsWith(dir + path.sep))) throw new Error(`source switch inventory: targets.ios.iosPackage.prepare runs in ${path.relative(repoTop, cwd) || "."}, outside the iOS scan roots (${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}); list that directory in targets.ios.ownershipScope`);
167
171
  }
168
172
  });