@rightkit/release 0.2.12 → 0.2.13

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.
@@ -35,9 +35,18 @@ if (first === "--version" || first === "-v") {
35
35
  } else {
36
36
  run("release.mjs", ["--doctor", ...args.slice(1)]);
37
37
  }
38
+ } else if (first === "suite-doctor") {
39
+ runTest(
40
+ ["--test", path.join(packageRoot, "right-suite-contract.test.mjs")],
41
+ "[right-release] suite-doctor passed",
42
+ );
38
43
  } else if (first === "publish") {
39
44
  const rest = args.slice(1);
40
- run("release.mjs", rest.includes("--upload") || rest.includes("--publish") ? rest : [...rest, "--upload"]);
45
+ if (rest[0] === "cargo") {
46
+ run("publish-cargo.mjs", rest.slice(1));
47
+ } else {
48
+ run("release.mjs", rest.includes("--upload") || rest.includes("--publish") ? rest : [...rest, "--upload"]);
49
+ }
41
50
  } else if (first === "lsclean") {
42
51
  runBinary("bash", [path.join(packageRoot, "lsclean.sh"), ...args.slice(1)]);
43
52
  } else if (first === "generate-dmg-background") {
@@ -66,7 +75,7 @@ function run(script, scriptArgs) {
66
75
  child.on("exit", (code) => process.exit(code ?? 1));
67
76
  }
68
77
 
69
- function runTest(testArgs) {
78
+ function runTest(testArgs, successMessage) {
70
79
  const child = spawn(process.execPath, testArgs, {
71
80
  cwd: process.cwd(),
72
81
  env: process.env,
@@ -77,7 +86,10 @@ function runTest(testArgs) {
77
86
  console.error(`right-release: failed to start doctor --all: ${error.message}`);
78
87
  process.exit(1);
79
88
  });
80
- child.on("exit", (code) => process.exit(code ?? 1));
89
+ child.on("exit", (code) => {
90
+ if (code === 0 && successMessage) console.log(successMessage);
91
+ process.exit(code ?? 1);
92
+ });
81
93
  }
82
94
 
83
95
  function runBinary(cmd, cmdArgs) {
@@ -100,8 +112,10 @@ function printHelp() {
100
112
  Commands:
101
113
  release [--platform mac|win] --tier patch|update Build/package through the signed release lane
102
114
  publish [--platform mac|win] --tier patch|update Release plus R2 upload + RightApps registration
115
+ publish cargo --crate <name> [--dry-run] Test, inspect, scan, and publish one crates.io package
103
116
  doctor [--platform mac|win] Inspect one app's release config
104
117
  doctor --all Verify all Right Suite app release contracts
118
+ suite-doctor Verify all local Right Suite repositories
105
119
  deps --check|--audit|--update Shared dependency lane
106
120
  hardening <artifact...> Run the Right Suite hardening scan
107
121
  lsclean <AppName.app> Clear macOS LaunchServices duplicates
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const FORBIDDEN_PACKAGE_PATH = /(^|\/)(?:\.env(?:\.[^/]*)?|target(?:\/.*)?|node_modules(?:\/.*)?|[^/]+\.(?:p8|p12|pem|key))$/i;
8
+ const SECRET_PATTERNS = [
9
+ /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
10
+ /\bAKIA[0-9A-Z]{16}\b/,
11
+ /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/,
12
+ /\bnpm_[A-Za-z0-9]{30,}\b/,
13
+ ];
14
+
15
+ export function parseCargoPublishArgs(argv, cwd = process.cwd()) {
16
+ const crate = valueAfter(argv, "--crate");
17
+ if (!crate) throw new Error("publish cargo requires --crate <name>");
18
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(crate)) throw new Error(`invalid crate name: ${crate}`);
19
+ const allowed = new Set(["--crate", "--workspace", "--dry-run", "--allow-dirty"]);
20
+ for (let index = 0; index < argv.length; index += 1) {
21
+ const arg = argv[index];
22
+ if (!arg.startsWith("--")) continue;
23
+ if (!allowed.has(arg)) throw new Error(`unknown publish cargo option: ${arg}`);
24
+ if (arg !== "--dry-run" && arg !== "--allow-dirty") index += 1;
25
+ }
26
+ const dryRun = argv.includes("--dry-run");
27
+ const allowDirty = argv.includes("--allow-dirty");
28
+ if (allowDirty && !dryRun) throw new Error("--allow-dirty is only valid with --dry-run");
29
+ return {
30
+ crate,
31
+ dryRun,
32
+ allowDirty,
33
+ workspaceRoot: path.resolve(valueAfter(argv, "--workspace") ?? cwd),
34
+ };
35
+ }
36
+
37
+ export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = false }) {
38
+ const dryRunArgs = ["publish", "--dry-run", "-p", crate];
39
+ const packageArgs = ["package", "--list", "-p", crate];
40
+ if (allowDirty) dryRunArgs.push("--allow-dirty");
41
+ if (allowDirty) packageArgs.push("--allow-dirty");
42
+ const plan = [
43
+ { label: "secret scan", internal: "secret-scan", crateDir },
44
+ { label: "format", command: "cargo", args: ["fmt", "--all", "--check"] },
45
+ { label: "tests", command: "cargo", args: ["test", "-p", crate] },
46
+ { label: "clippy", command: "cargo", args: ["clippy", "-p", crate, "--all-targets", "--", "-D", "warnings"] },
47
+ { label: "package contents", command: "cargo", args: packageArgs, capture: true },
48
+ { label: "registry dry-run", command: "cargo", args: dryRunArgs },
49
+ ];
50
+ if (!dryRun) plan.push({ label: "registry publish", command: "cargo", args: ["publish", "-p", crate] });
51
+ return plan;
52
+ }
53
+
54
+ export function packageFileViolations(files) {
55
+ return files
56
+ .map((file) => file.trim().replaceAll("\\", "/"))
57
+ .filter(Boolean)
58
+ .filter((file) => FORBIDDEN_PACKAGE_PATH.test(file));
59
+ }
60
+
61
+ export function scanCrateForSecrets(crateDir) {
62
+ const violations = [];
63
+ for (const file of walkFiles(crateDir)) {
64
+ const relative = path.relative(crateDir, file).replaceAll("\\", "/");
65
+ if (FORBIDDEN_PACKAGE_PATH.test(relative)) {
66
+ violations.push(relative);
67
+ continue;
68
+ }
69
+ const bytes = fs.readFileSync(file);
70
+ if (bytes.includes(0)) continue;
71
+ const text = bytes.toString("utf8");
72
+ if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) violations.push(relative);
73
+ }
74
+ return [...new Set(violations)].sort();
75
+ }
76
+
77
+ export function runCargoPublish(options, execute = spawnSync) {
78
+ const crateDir = path.join(options.workspaceRoot, "crates", options.crate);
79
+ const manifest = path.join(crateDir, "Cargo.toml");
80
+ if (!fs.existsSync(manifest)) throw new Error(`crate manifest not found: ${manifest}`);
81
+ const plan = buildCargoPublishPlan({ ...options, crateDir });
82
+
83
+ for (const step of plan) {
84
+ process.stdout.write(`[right-release] cargo ${step.label}\n`);
85
+ if (step.internal === "secret-scan") {
86
+ const violations = scanCrateForSecrets(crateDir);
87
+ if (violations.length) throw new Error(`secret scan rejected: ${violations.join(", ")}`);
88
+ continue;
89
+ }
90
+ const result = execute(step.command, step.args, {
91
+ cwd: options.workspaceRoot,
92
+ encoding: "utf8",
93
+ windowsHide: true,
94
+ stdio: step.capture ? "pipe" : "inherit",
95
+ });
96
+ if (result.error) throw result.error;
97
+ if (result.status !== 0) {
98
+ if (step.capture && result.stderr) process.stderr.write(result.stderr);
99
+ throw new Error(`${step.label} failed with exit code ${result.status}`);
100
+ }
101
+ if (step.capture) {
102
+ if (result.stderr) process.stderr.write(result.stderr);
103
+ const files = String(result.stdout ?? "").split(/\r?\n/).filter(Boolean);
104
+ const violations = packageFileViolations(files);
105
+ if (violations.length) throw new Error(`package contents rejected: ${violations.join(", ")}`);
106
+ process.stdout.write(`${files.join("\n")}\n`);
107
+ }
108
+ }
109
+ }
110
+
111
+ function walkFiles(root) {
112
+ const files = [];
113
+ const pending = [root];
114
+ while (pending.length) {
115
+ const current = pending.pop();
116
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
117
+ if (entry.isDirectory() && [".git", "target", "node_modules"].includes(entry.name)) continue;
118
+ const absolute = path.join(current, entry.name);
119
+ if (entry.isDirectory()) pending.push(absolute);
120
+ else if (entry.isFile()) files.push(absolute);
121
+ }
122
+ }
123
+ return files;
124
+ }
125
+
126
+ function valueAfter(argv, flag) {
127
+ const index = argv.indexOf(flag);
128
+ return index >= 0 ? argv[index + 1] : undefined;
129
+ }
130
+
131
+ function main() {
132
+ const options = parseCargoPublishArgs(process.argv.slice(2));
133
+ runCargoPublish(options);
134
+ process.stdout.write(`[right-release] ${options.dryRun ? "validated" : "published"} ${options.crate}\n`);
135
+ }
136
+
137
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
138
+ try {
139
+ main();
140
+ } catch (error) {
141
+ process.stderr.write(`right-release publish cargo: ${error instanceof Error ? error.message : String(error)}\n`);
142
+ process.exitCode = 1;
143
+ }
144
+ }
@@ -0,0 +1,43 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ buildCargoPublishPlan,
5
+ packageFileViolations,
6
+ parseCargoPublishArgs,
7
+ } from "./publish-cargo.mjs";
8
+
9
+ test("cargo publish requires a safe explicit crate name", () => {
10
+ assert.throws(() => parseCargoPublishArgs([]), /--crate/);
11
+ assert.throws(() => parseCargoPublishArgs(["--crate", "../secret"]), /invalid crate/);
12
+ assert.throws(() => parseCargoPublishArgs(["--crate", "rightkit-license", "--allow-dirty"]), /only valid with --dry-run/);
13
+ assert.equal(parseCargoPublishArgs(["--crate", "rightkit-license"]).crate, "rightkit-license");
14
+ });
15
+
16
+ test("cargo publish plan always runs local safety gates before upload", () => {
17
+ const plan = buildCargoPublishPlan({ crate: "rightkit-license", crateDir: "C:/rightkit/crates/rightkit-license", dryRun: false });
18
+ assert.deepEqual(plan.map((step) => step.label), [
19
+ "secret scan",
20
+ "format",
21
+ "tests",
22
+ "clippy",
23
+ "package contents",
24
+ "registry dry-run",
25
+ "registry publish",
26
+ ]);
27
+ assert.deepEqual(plan.at(-1)?.args, ["publish", "-p", "rightkit-license"]);
28
+ });
29
+
30
+ test("dry-run plan never contains a real registry upload", () => {
31
+ const plan = buildCargoPublishPlan({ crate: "rightkit-logs", crateDir: "C:/rightkit/crates/rightkit-logs", dryRun: true });
32
+ assert.equal(plan.at(-1)?.label, "registry dry-run");
33
+ });
34
+
35
+ test("package inspection rejects secret and build-output paths", () => {
36
+ assert.deepEqual(packageFileViolations([
37
+ "Cargo.toml",
38
+ "src/lib.rs",
39
+ ".env",
40
+ "keys/signing.p8",
41
+ "target/release/app",
42
+ ]), [".env", "keys/signing.p8", "target/release/app"]);
43
+ });
@@ -11,6 +11,7 @@ const UPLOAD = path.join(TOOL_ROOT, "upload-large.mjs");
11
11
  const API_BASE = process.env.RIGHTAPPS_API_URL || "https://api.spoares.com";
12
12
  const DOWNLOAD_BASE = process.env.RIGHTAPPS_UPDATE_DOWNLOAD_BASE || "https://rightapps-license-gate.adrdsouza.workers.dev";
13
13
  const PUBLIC_BASE = process.env.RIGHTAPPS_PUBLIC_DOWNLOAD_BASE || "https://pub-6c73208d46c245a9b4881d5e02f6b618.r2.dev";
14
+ const PIPELINE_VERSION = JSON.parse(await readFile(path.join(TOOL_ROOT, "package.json"), "utf8")).version;
14
15
  const tier = process.env.RIGHT_RELEASE_TIER || "";
15
16
  const args = process.argv.slice(2);
16
17
  let configName = "right-release.config.mjs";
@@ -45,6 +46,8 @@ if (tier === "patch") {
45
46
  }
46
47
  const platforms = {};
47
48
  const registrations = new Map();
49
+ const updaterManifestArtifacts = new Map();
50
+ const installerManifestArtifacts = new Map();
48
51
  const uploadedKeys = new Set();
49
52
  const uploadedSignatures = new Set();
50
53
  for (const artifact of artifacts) {
@@ -75,6 +78,12 @@ if (tier === "patch") {
75
78
  url: `${PUBLIC_BASE}/${installerKey}`,
76
79
  };
77
80
  registrations.set(installerKey, { artifactKey, path: installerKey, ...metadata });
81
+ updaterManifestArtifacts.set(installerKey, {
82
+ kind: "updater",
83
+ r2Key: installerKey,
84
+ ...metadata,
85
+ updaterSignature: signature,
86
+ });
78
87
  }
79
88
  for (const installer of installers) {
80
89
  const file = path.resolve(root, installer.file);
@@ -83,6 +92,13 @@ if (tier === "patch") {
83
92
  if (!dryRun) await run(process.execPath, [UPLOAD, file, installer.key, "public"], root);
84
93
  uploadedKeys.add(installer.key);
85
94
  }
95
+ const metadata = dryRun ? { sha256: null, sizeBytes: null } : await fileMetadata(file);
96
+ installerManifestArtifacts.set(installer.key, {
97
+ kind: "installer",
98
+ r2Key: installer.key,
99
+ ...metadata,
100
+ updaterSignature: null,
101
+ });
86
102
  }
87
103
  if (!artifacts.length) {
88
104
  console.log(`${dryRun ? "[dry-run] " : ""}NO patch updater artifacts; skipping patch manifest registration`);
@@ -112,6 +128,13 @@ if (tier === "patch") {
112
128
  platforms,
113
129
  },
114
130
  artifacts: [...registrations.values()],
131
+ artifactManifest: buildArtifactManifest({
132
+ appKey: config.app,
133
+ appVersion: version,
134
+ tier,
135
+ platform,
136
+ artifacts: [...installerManifestArtifacts.values(), ...updaterManifestArtifacts.values()],
137
+ }),
115
138
  };
116
139
  console.log(`${dryRun ? "[dry-run] " : ""}POST ${API_BASE}/v1/admin/apps/patches`);
117
140
  console.log(JSON.stringify(body));
@@ -132,6 +155,7 @@ if (!artifacts.length) fail(`${config?.app ?? "app"} ${platform} update has no u
132
155
 
133
156
  const platforms = {};
134
157
  const registrations = new Map();
158
+ const updaterManifestArtifacts = new Map();
135
159
  const uploadedKeys = new Set();
136
160
  const uploadedSignatures = new Set();
137
161
  for (const artifact of artifacts) {
@@ -161,6 +185,12 @@ for (const artifact of artifacts) {
161
185
  url: `${DOWNLOAD_BASE}/${artifact.key}`,
162
186
  };
163
187
  registrations.set(artifact.key, { artifactKey, path: artifact.key, ...metadata });
188
+ updaterManifestArtifacts.set(artifact.key, {
189
+ kind: "updater",
190
+ r2Key: artifact.key,
191
+ ...metadata,
192
+ updaterSignature: signature,
193
+ });
164
194
  }
165
195
 
166
196
  const version = config.version || target.updater.version;
@@ -178,6 +208,13 @@ const body = {
178
208
  platforms,
179
209
  },
180
210
  artifacts: [...registrations.values()],
211
+ artifactManifest: buildArtifactManifest({
212
+ appKey: config.app,
213
+ appVersion: version,
214
+ tier,
215
+ platform,
216
+ artifacts: [...updaterManifestArtifacts.values()],
217
+ }),
181
218
  };
182
219
  const route = "/v1/admin/apps/releases";
183
220
  console.log(`${dryRun ? "[dry-run] " : ""}POST ${API_BASE}${route}`);
@@ -202,6 +239,18 @@ async function fileMetadata(file) {
202
239
  };
203
240
  }
204
241
 
242
+ function buildArtifactManifest({ appKey, appVersion, tier, platform, artifacts }) {
243
+ return {
244
+ schema: 1,
245
+ pipelineVersion: PIPELINE_VERSION,
246
+ appKey,
247
+ appVersion,
248
+ tier,
249
+ platform: platform === "mac" ? "darwin" : "windows",
250
+ artifacts,
251
+ };
252
+ }
253
+
205
254
  function run(cmd, runArgs, cwd) {
206
255
  return new Promise((resolve, reject) => {
207
256
  const child = spawn(cmd, runArgs, { cwd, env: process.env, stdio: "inherit", windowsHide: true });
@@ -76,6 +76,14 @@ function run(tier) {
76
76
  });
77
77
  }
78
78
 
79
+ function registrationBody(result) {
80
+ return result.stdout
81
+ .split(/\r?\n/)
82
+ .filter((line) => line.startsWith('{'))
83
+ .map((line) => JSON.parse(line))
84
+ .find((body) => body.artifactManifest);
85
+ }
86
+
79
87
  test("patch registers a free updater manifest backed by public installer-path objects", () => {
80
88
  const result = run("patch");
81
89
  assert.equal(result.status, 0, result.stderr);
@@ -95,6 +103,23 @@ test("patch registers a free updater manifest backed by public installer-path ob
95
103
  assert.equal((result.stdout.match(/UPLOAD public\/fixture\/installers\/windows\/current\/Fixture\.exe\.sig/g) ?? []).length, 1);
96
104
  assert.match(result.stdout, /PRUNE rightapps-downloads\/fixture\/windows/);
97
105
  assert.match(result.stdout, /PRUNE rightapps-updates\/fixture\/windows keep=<none>/);
106
+ const body = registrationBody(result);
107
+ assert.deepEqual(
108
+ {
109
+ schema: body.artifactManifest.schema,
110
+ appKey: body.artifactManifest.appKey,
111
+ appVersion: body.artifactManifest.appVersion,
112
+ tier: body.artifactManifest.tier,
113
+ platform: body.artifactManifest.platform,
114
+ },
115
+ { schema: 1, appKey: 'fixture', appVersion: '1.2.3', tier: 'patch', platform: 'windows' },
116
+ );
117
+ assert.match(body.artifactManifest.pipelineVersion, /^\d+\.\d+\.\d+$/);
118
+ assert.deepEqual(body.artifactManifest.artifacts.map((artifact) => [artifact.kind, artifact.r2Key]), [
119
+ ['installer', 'fixture/installers/windows/current/Fixture-Setup.exe'],
120
+ ['updater', 'fixture/installers/windows/current/Fixture.exe'],
121
+ ]);
122
+ assert.match(body.artifactManifest.artifacts[1].updaterSignature, /^<signature:/);
98
123
  });
99
124
 
100
125
  test("routes feature updates to the Pro-gated release endpoint", () => {
@@ -108,6 +133,11 @@ test("routes feature updates to the Pro-gated release endpoint", () => {
108
133
  assert.match(result.stdout, /PRUNE rightapps-downloads\/fixture\/windows/);
109
134
  assert.match(result.stdout, /PRUNE rightapps-updates\/fixture\/windows/);
110
135
  assert.doesNotMatch(result.stdout, /UPLOAD .*Fixture-Setup\.exe/);
136
+ const body = registrationBody(result);
137
+ assert.equal(body.artifactManifest.tier, 'update');
138
+ assert.deepEqual(body.artifactManifest.artifacts.map((artifact) => [artifact.kind, artifact.r2Key]), [
139
+ ['updater', 'fixture/updates/windows/current/Fixture.exe'],
140
+ ]);
111
141
  });
112
142
 
113
143
  test("allows an installer-only patch without registering an updater manifest", () => {
@@ -19,10 +19,34 @@ const apps = [
19
19
  test("RightKit exposes one current version manifest", () => {
20
20
  assert.equal(existsSync(versionsPath), true, "rightkit-versions.json must be the single current-version source");
21
21
  assert.match(versions.packageManager, /^pnpm@\d+\.\d+\.\d+$/);
22
- assert.equal(versions.npm["@rightkit/release"], "0.2.12");
22
+ assert.equal(versions.npm["@rightkit/release"], "0.2.13");
23
23
  assert.equal(versions.npm["@rightkit/license"], "0.1.5");
24
24
  assert.equal(versions.npm["@rightkit/logs"], "0.1.3");
25
25
  assert.equal(versions.npm["@rightkit/updates"], "0.1.1");
26
+ assert.equal(versions.cargo["rightkit-license"], "0.1.1");
27
+ assert.equal(versions.cargo["rightkit-logs"], "0.1.0");
28
+ for (const [crate, version] of Object.entries(versions.cargo)) {
29
+ const manifest = readFileSync(path.join(workspace, `tools/rightkit/crates/${crate}/Cargo.toml`), "utf8");
30
+ assert.match(manifest, new RegExp(`^version\\s*=\\s*"${version.replaceAll(".", "\\.")}"$`, "m"));
31
+ }
32
+ });
33
+
34
+ test("license v2 public vector is identical at every portable consumer boundary", () => {
35
+ const canonical = readFileSync(
36
+ path.join(workspace, "tools/rightkit/crates/rightkit-license/test-vectors/license-v2.json"),
37
+ "utf8",
38
+ );
39
+ for (const relativePath of [
40
+ "tools/rightkit/packages/license/test-vectors/license-v2.json",
41
+ "rightapps/packages/api/src/licensing/test-vectors/license-v2.json",
42
+ "scraperight/tests/fixtures/license-v2.json",
43
+ ]) {
44
+ assert.equal(
45
+ readFileSync(path.join(workspace, relativePath), "utf8"),
46
+ canonical,
47
+ `${relativePath} must be the canonical public license vector byte-for-byte`,
48
+ );
49
+ }
26
50
  });
27
51
 
28
52
  for (const app of apps) {
@@ -4,7 +4,11 @@
4
4
  "npm": {
5
5
  "@rightkit/license": "0.1.5",
6
6
  "@rightkit/logs": "0.1.3",
7
- "@rightkit/release": "0.2.12",
7
+ "@rightkit/release": "0.2.13",
8
8
  "@rightkit/updates": "0.1.1"
9
+ },
10
+ "cargo": {
11
+ "rightkit-license": "0.1.1",
12
+ "rightkit-logs": "0.1.0"
9
13
  }
10
14
  }
@@ -0,0 +1,19 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import path from "node:path";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const packageRoot = path.dirname(fileURLToPath(import.meta.url));
8
+ const cli = path.join(packageRoot, "cli", "right-release.mjs");
9
+
10
+ test("suite-doctor runs the complete local multi-repo contract", () => {
11
+ const result = spawnSync(process.execPath, [cli, "suite-doctor"], {
12
+ cwd: packageRoot,
13
+ encoding: "utf8",
14
+ windowsHide: true,
15
+ });
16
+
17
+ assert.equal(result.status, 0, result.stderr || result.stdout);
18
+ assert.match(result.stdout, /\[right-release\] suite-doctor passed/);
19
+ });