vydanne 0.14.0 → 1.0.0-rc.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.
- package/README.md +40 -0
- package/SKILL.md +8 -0
- package/bin/vydanne.mjs +33 -20
- package/package.json +5 -3
- package/src/commands/prepare.mjs +12 -3
- package/src/commands/withdraw.mjs +6 -1
- package/src/config.mjs +5 -1
- package/src/index.mjs +25 -9
- package/src/middleware.mjs +125 -0
- package/types/index.d.ts +56 -0
package/README.md
CHANGED
|
@@ -440,6 +440,46 @@ don't have to learn them the hard way.
|
|
|
440
440
|
|
|
441
441
|
<br>
|
|
442
442
|
|
|
443
|
+
## Middleware — your own checks around any command
|
|
444
|
+
|
|
445
|
+
A release has rules that belong to your app, not to vydanne: "never upload a bundle that has not opened on the lowest
|
|
446
|
+
Android we support", "tell the channel when a build reaches testers", "refuse a Friday". `middleware` in the config is
|
|
447
|
+
where that logic goes, so you do not wrap the CLI in a script that someone can bypass by calling vydanne directly.
|
|
448
|
+
|
|
449
|
+
```js
|
|
450
|
+
// vydanne.config.mjs
|
|
451
|
+
export default {
|
|
452
|
+
// …
|
|
453
|
+
middleware: [
|
|
454
|
+
// an object limits an entry to the commands and stores it names
|
|
455
|
+
{
|
|
456
|
+
name: "release-gate",
|
|
457
|
+
commands: ["prerelease"],
|
|
458
|
+
stores: ["google"],
|
|
459
|
+
async run(ctx, next) {
|
|
460
|
+
if (ctx.apply && !hasReceipt(ctx.config.google.aab)) ctx.fail("this bundle has not passed the legacy-device check");
|
|
461
|
+
const result = await next(); // the command itself
|
|
462
|
+
if (ctx.apply && result?.ok) await tell("build is with testers");
|
|
463
|
+
return result; // returning nothing keeps the command's own result
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
// a bare function runs for every store command
|
|
467
|
+
async (ctx, next) => { ctx.log(`${ctx.command} on ${ctx.store}`); return next(); },
|
|
468
|
+
],
|
|
469
|
+
};
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
The first entry is outermost. Before `await next()` you may refuse: `ctx.fail(reason)` stops the command, prints
|
|
473
|
+
`refused by middleware release-gate: …` and exits 1, before any client exists, so a refusal costs no Play or App Store
|
|
474
|
+
Connect request. After it you see the result (`{ ok, planned }`). An entry that never calls `next()` skips the command and
|
|
475
|
+
says so. `ctx` carries `command`, `store`, `apply`, `dryRun`, `writes`, `argv`, `cwd`, `env`, `version`, and the live
|
|
476
|
+
`config`: a change made before `next()` (a different `google.track`, say) is what the command sees.
|
|
477
|
+
|
|
478
|
+
It wraps every store command, from the CLI and from `runCommand`. It does not wrap `auth`, `locales`, `version` or help, which
|
|
479
|
+
touch no store. A malformed entry is an error when the config loads. Middleware runs with the full power of the file it
|
|
480
|
+
lives in, so it is not a sandbox. The same `middleware` key, with the same semantics, is in
|
|
481
|
+
[zdymak](https://github.com/Lonli-Lokli/zdymak), so a check written for one reads the same in the other.
|
|
482
|
+
|
|
443
483
|
## What vydanne will never do
|
|
444
484
|
|
|
445
485
|
- **It never submits for review, and never ships to the public.** It *will* put a build in front of
|
package/SKILL.md
CHANGED
|
@@ -397,6 +397,14 @@ It waits for the version to actually leave review before returning. Apple passes
|
|
|
397
397
|
`CANCELING` first, and a command that returned there would send you straight into a `fill` that
|
|
398
398
|
fails on a still-locked version.
|
|
399
399
|
|
|
400
|
+
## `middleware` — the app's own logic around commands
|
|
401
|
+
|
|
402
|
+
The config key `middleware` takes `(ctx, next)` functions or `{ name, commands, stores, run }` objects, first entry outermost.
|
|
403
|
+
Before `await next()` an entry may refuse with `ctx.fail(reason)`; after it, it sees `{ ok, planned }`. It runs for every store
|
|
404
|
+
command (CLI and `runCommand`), before any client exists, and not for `auth`/`locales`/`version`/help. Use it for a release
|
|
405
|
+
gate or a notifier rather than a wrapper script, which a direct `vydanne prerelease --apply` bypasses. Full example: README,
|
|
406
|
+
"Middleware". The same key exists in zdymak.
|
|
407
|
+
|
|
400
408
|
## `--apply` — writes are opt-in
|
|
401
409
|
|
|
402
410
|
**Every store-mutating command is a DRY RUN without `--apply`**: `prepare` · `push` · `fill` ·
|
package/bin/vydanne.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { loadConfig } from "../src/config.mjs";
|
|
|
6
6
|
import { Client } from "../src/client.mjs";
|
|
7
7
|
import { COMMANDS, PLAY_COMMANDS } from "../src/registry.mjs";
|
|
8
8
|
import { yellow } from "../src/util.mjs";
|
|
9
|
+
import { runMiddleware } from "../src/middleware.mjs";
|
|
9
10
|
|
|
10
11
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url))).version;
|
|
11
12
|
|
|
@@ -33,6 +34,12 @@ if (store !== "apple" && store !== "google") {
|
|
|
33
34
|
// keep working unchanged; `--apply` is what the docs teach.
|
|
34
35
|
const apply = argv.includes("--apply") || process.env.VYDANNE_COMMIT === "1";
|
|
35
36
|
|
|
37
|
+
// USER MIDDLEWARE (config `middleware`) wraps every STORE command. Not `auth`, `locales`, `version` or help: those
|
|
38
|
+
// touch no store, so there is nothing for a release gate or a notifier to guard.
|
|
39
|
+
const through = (cfg, execute, extra = {}) => runMiddleware(cfg.middleware, {
|
|
40
|
+
tool: "vydanne", version: VERSION, command: cmd, store, apply, argv, config: cfg, cwd: process.cwd(), env: process.env, ...extra,
|
|
41
|
+
}, execute);
|
|
42
|
+
|
|
36
43
|
try {
|
|
37
44
|
if (["version", "-v", "--version"].includes(cmd)) {
|
|
38
45
|
console.log(`vydanne ${VERSION}`);
|
|
@@ -81,31 +88,37 @@ try {
|
|
|
81
88
|
const { PlayClient } = await import("../src/play/client.mjs");
|
|
82
89
|
const spec = PLAY_COMMANDS[cmd];
|
|
83
90
|
const dryRun = Boolean(spec.writes) && !apply;
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
// Middleware runs BEFORE the client exists, so a refusal costs no authentication and no Play request.
|
|
92
|
+
const result = await through(cfg, async () => {
|
|
93
|
+
// Play needs no request-level gate: every mutation happens inside an Edit, and an Edit that is never
|
|
94
|
+
// committed changes nothing. So a dry run here VALIDATES for real against Google, then discards.
|
|
95
|
+
const client = await PlayClient.create({ keyPath: cfg.google.serviceAccountKey, packageName: cfg.google.packageName, dryRun });
|
|
96
|
+
if (dryRun) console.log(yellow(`DRY RUN — '${cmd} --store google' validates against Play and discards the edit. Add --apply to commit.`));
|
|
97
|
+
const { run } = await import(`../src/play/commands/${spec.mod}.mjs`);
|
|
98
|
+
return { ok: (await run(cfg, client)) !== false, planned: [] };
|
|
99
|
+
}, { dryRun, writes: Boolean(spec.writes) });
|
|
100
|
+
if (result?.ok === false) process.exit(1);
|
|
91
101
|
} else if (COMMANDS[cmd]) {
|
|
92
102
|
const cfg = await loadConfig(cfgPath);
|
|
93
103
|
const spec = COMMANDS[cmd];
|
|
94
104
|
const { run } = await import(`../src/commands/${spec.mod}.mjs`);
|
|
95
105
|
const dryRun = Boolean(spec.writes) && !apply;
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
const result = await through(cfg, async () => {
|
|
107
|
+
const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId, keyPath: cfg.keyPath, keyContent: cfg.keyContent, dryRun }) : null;
|
|
108
|
+
if (dryRun) console.log(yellow(`DRY RUN — '${cmd}' will not change App Store Connect. Add --apply to write.`));
|
|
109
|
+
// altool authenticates on its own rather than through our JWT, so it needs the raw ids.
|
|
110
|
+
const ok = await run(cfg, client, spec.credentials ? { keyId: cfg.keyId, issuerId: cfg.issuerId } : undefined);
|
|
111
|
+
// The count is the point: "nothing happened" is not the same as "nothing would happen", and only the
|
|
112
|
+
// second one means the local state already matches the store.
|
|
113
|
+
if (dryRun && client) {
|
|
114
|
+
const n = client.planned.length;
|
|
115
|
+
console.log(yellow(n
|
|
116
|
+
? `DRY RUN — ${n} store write(s) withheld. Re-run with --apply to perform them.`
|
|
117
|
+
: "DRY RUN — nothing to write; the store already matches local."));
|
|
118
|
+
}
|
|
119
|
+
return { ok: ok !== false, planned: client?.planned ?? [] };
|
|
120
|
+
}, { dryRun, writes: Boolean(spec.writes) });
|
|
121
|
+
if (result?.ok === false) process.exit(1);
|
|
109
122
|
} else {
|
|
110
123
|
console.error(usage());
|
|
111
124
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vydanne",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
4
4
|
"description": "App Store Connect + Google Play submission prep — the companion to zdymak (media). Native Node (no fastlane/Ruby/Python): localized listings, screenshot/preview/icon upload, ratings, review contact, accessibility & privacy labels, IAP, export docs, build upload to TestFlight / a Play closed track, a diff of local-vs-store, and a preflight verifier that encodes the store gotchas. Never submits for review. iOS/macOS via the ASC REST API; Android via the Play Developer Edits API (--store google).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"app-store-connect",
|
|
@@ -52,11 +52,13 @@
|
|
|
52
52
|
"check:docs": "node scripts/check-docs.mjs",
|
|
53
53
|
"check:types": "tsc --noEmit -p tsconfig.json && node scripts/check-types.mjs",
|
|
54
54
|
"check:config": "node scripts/check-config.mjs",
|
|
55
|
-
"verify": "npm run check:docs && npm run check:types && npm run check:config",
|
|
55
|
+
"verify": "npm run check:docs && npm run check:types && npm run check:config && npm run check:middleware && npm run check:release",
|
|
56
56
|
"prepublishOnly": "npm run verify",
|
|
57
57
|
"release:patch": "node scripts/release.mjs patch",
|
|
58
58
|
"release:minor": "node scripts/release.mjs minor",
|
|
59
|
-
"release:major": "node scripts/release.mjs major"
|
|
59
|
+
"release:major": "node scripts/release.mjs major",
|
|
60
|
+
"check:middleware": "node --test scripts/middleware.test.mjs",
|
|
61
|
+
"check:release": "node --test scripts/release-from-tag.test.mjs"
|
|
60
62
|
},
|
|
61
63
|
"dependencies": {
|
|
62
64
|
"dotenv": "^17.4.2",
|
package/src/commands/prepare.mjs
CHANGED
|
@@ -54,10 +54,19 @@ async function versionsFor(client, platform) {
|
|
|
54
54
|
return json.data || [];
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
57
|
+
/**
|
|
58
|
+
* The newest build FOR THIS PLATFORM, together with the marketing version its archive declares.
|
|
59
|
+
*
|
|
60
|
+
* Filtered by platform, which it was not: an app shipping on iOS and the Mac App Store uploads a build
|
|
61
|
+
* to each, and the unfiltered query returned whichever went up last. Preparing MAC_OS then tried to
|
|
62
|
+
* attach an iOS build and Apple answered 409 — so the Mac version was left holding nothing, or an older
|
|
63
|
+
* build, purely because the iOS one was newer. The 409 made it visible; a same-platform mix-up would
|
|
64
|
+
* not have been.
|
|
65
|
+
*/
|
|
66
|
+
async function newestBuildWithVersion(client, platform) {
|
|
59
67
|
const { json } = await client.get(
|
|
60
68
|
`/v1/builds?filter[app]=${client.appId}&sort=-uploadedDate&limit=1` +
|
|
69
|
+
(platform ? `&filter[preReleaseVersion.platform]=${platform}` : "") +
|
|
61
70
|
`&fields[builds]=version,processingState&include=preReleaseVersion` +
|
|
62
71
|
`&fields[preReleaseVersions]=version`,
|
|
63
72
|
);
|
|
@@ -218,7 +227,7 @@ export async function run(config, client) {
|
|
|
218
227
|
async function prepareOne(config, client, platform) {
|
|
219
228
|
console.log(green(`prepare → App Store version (${platform})`));
|
|
220
229
|
|
|
221
|
-
const { build, marketing } = await newestBuildWithVersion(client);
|
|
230
|
+
const { build, marketing } = await newestBuildWithVersion(client, platform);
|
|
222
231
|
const target = process.env.VYDANNE_VERSION || marketing;
|
|
223
232
|
|
|
224
233
|
if (!target) {
|
|
@@ -56,7 +56,12 @@ export async function run(config, client) {
|
|
|
56
56
|
console.log(" no versions on this app");
|
|
57
57
|
return true;
|
|
58
58
|
}
|
|
59
|
-
|
|
59
|
+
// Pick the version that is ACTUALLY in review, not merely the first one Apple returned. An app on two
|
|
60
|
+
// platforms has two 1.2 records, and `mine[0]` was whichever came back first: with iOS
|
|
61
|
+
// WAITING_FOR_REVIEW and macOS PREPARE_FOR_SUBMISSION, this reported "not submitted — nothing to
|
|
62
|
+
// withdraw" and exited 0, never looking at the submission it was asked to cancel. Silence is the worst
|
|
63
|
+
// possible answer here, because the operator walks away believing the withdrawal happened.
|
|
64
|
+
const version = mine.find((v) => WITHDRAWABLE[v.attributes.appStoreState]) || mine[0];
|
|
60
65
|
const state = version.attributes.appStoreState;
|
|
61
66
|
const label = `${version.attributes.versionString} (${state})`;
|
|
62
67
|
|
package/src/config.mjs
CHANGED
|
@@ -5,10 +5,11 @@ import { resolveLocales } from "./locales.mjs";
|
|
|
5
5
|
import { resolveCredentials } from "./credentials.mjs";
|
|
6
6
|
import { DEFAULT_SCREENSHOT_BASE } from "./screenshots.mjs";
|
|
7
7
|
import { DEFAULT_PLAY_IMAGES } from "./play/images.mjs";
|
|
8
|
+
import { normalizeMiddleware } from "./middleware.mjs";
|
|
8
9
|
|
|
9
10
|
// The public config surface — the drift guards assert each key is documented (README/SKILL) and typed
|
|
10
11
|
// (types/index.d.ts). Add a config knob → document + type it, or the guards fail before publish.
|
|
11
|
-
export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "localeMap", "metadataDir", "screenshots", "rating", "ageRating", "categories", "contentRights", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "bridge", "push", "reviewContact", "allowCrossStoreTerms", "buildNumberOffset"];
|
|
12
|
+
export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "localeMap", "metadataDir", "screenshots", "rating", "ageRating", "categories", "contentRights", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "bridge", "push", "reviewContact", "allowCrossStoreTerms", "buildNumberOffset", "middleware"];
|
|
12
13
|
|
|
13
14
|
// One `vydanne.config.mjs` per app (ESM, like zdymak.config.mjs) — nothing hard-coded. Secrets stay out:
|
|
14
15
|
// credentials resolve from the environment, a gitignored .env, or ~/.appstoreconnect/config.json (see
|
|
@@ -94,6 +95,9 @@ export async function loadConfig(p) {
|
|
|
94
95
|
// tool cannot verify, so it is refused unless it is an integer and reported wherever it is
|
|
95
96
|
// applied. 0 means the plain convention, which is every app that never had the accident.
|
|
96
97
|
buildNumberOffset: integerOr("buildNumberOffset", raw.buildNumberOffset, 0),
|
|
98
|
+
// The consumer's own logic around every store command: `[(ctx, next) => …]` or `{ name, commands, stores, run }`.
|
|
99
|
+
// Validated HERE so a malformed entry fails at config load, not halfway through a release. See middleware.mjs.
|
|
100
|
+
middleware: normalizeMiddleware(raw.middleware, "vydanne"),
|
|
97
101
|
// Terms the cross-store check must not flag for this app (see src/crossStore.mjs).
|
|
98
102
|
allowCrossStoreTerms: raw.allowCrossStoreTerms || [],
|
|
99
103
|
previews: raw.previews || null,
|
package/src/index.mjs
CHANGED
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
// filenames that nothing outside the package could resolve, because `exports` maps only ".". So a
|
|
5
5
|
// consumer could see that `fill` existed and had no way to run it. `runCommand` is that missing half:
|
|
6
6
|
// the same dispatch bin/ performs, minus the argv parsing and the process.exit.
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
7
8
|
import { Client } from "./client.mjs";
|
|
8
9
|
import { loadConfig } from "./config.mjs";
|
|
10
|
+
import { runMiddleware } from "./middleware.mjs";
|
|
9
11
|
import { COMMANDS, PLAY_COMMANDS } from "./registry.mjs";
|
|
10
12
|
|
|
11
13
|
export { Client } from "./client.mjs";
|
|
12
14
|
export { PlayClient } from "./play/client.mjs";
|
|
13
15
|
export { loadConfig, CONFIG_KEYS } from "./config.mjs";
|
|
16
|
+
export { MiddlewareRefusal, normalizeMiddleware, runMiddleware } from "./middleware.mjs";
|
|
14
17
|
export { COMMANDS, PLAY_COMMANDS, COMMAND_NAMES } from "./registry.mjs";
|
|
15
18
|
export { resolveLocales, toAsc, VALID, UI_TO_ASC } from "./locales.mjs";
|
|
16
19
|
export { makeToken, resolveKey } from "./jwt.mjs";
|
|
@@ -34,6 +37,7 @@ export { DEFAULT_PLAY_IMAGES, PLAY_IMAGE_KIND, playImages } from "./play/images.
|
|
|
34
37
|
* @param {string} [opts.configPath] path to vydanne.config.mjs, when loading from disk
|
|
35
38
|
* @param {"apple"|"google"} [opts.store]
|
|
36
39
|
* @param {boolean} [opts.apply] perform store writes (default false — dry run)
|
|
40
|
+
* @param {string[]} [opts.argv] the command-line arguments to show middleware (default: none)
|
|
37
41
|
*/
|
|
38
42
|
export async function runCommand(name, opts = {}) {
|
|
39
43
|
const { store = "apple", apply = false, configPath } = opts;
|
|
@@ -49,15 +53,27 @@ export async function runCommand(name, opts = {}) {
|
|
|
49
53
|
if (store === "google") {
|
|
50
54
|
if (!config.google) throw new Error("vydanne: no `google` block in config — add packageName + a service-account key");
|
|
51
55
|
if (!config.google.serviceAccountKey) throw new Error("vydanne: set PLAY_JSON_KEY_FILE (or google.serviceAccountKey) to the Play service-account JSON");
|
|
52
|
-
const { PlayClient } = await import("./play/client.mjs");
|
|
53
|
-
const client = await PlayClient.create({ keyPath: config.google.serviceAccountKey, packageName: config.google.packageName, dryRun });
|
|
54
|
-
const { run } = await import(`./play/commands/${spec.mod}.mjs`);
|
|
55
|
-
return { ok: (await run(config, client)) !== false, planned: [] };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
// The same `middleware` the CLI runs, with the same context, so a gate written for one holds for the other.
|
|
59
|
+
// `argv` is empty here: a library caller has no command line.
|
|
60
|
+
const ctx = {
|
|
61
|
+
tool: "vydanne", version: VERSION, command: name, store, apply, dryRun, writes: Boolean(spec.writes),
|
|
62
|
+
argv: opts.argv ?? [], config, cwd: process.cwd(), env: process.env,
|
|
63
|
+
};
|
|
64
|
+
return runMiddleware(config.middleware, ctx, async () => {
|
|
65
|
+
if (store === "google") {
|
|
66
|
+
const { PlayClient } = await import("./play/client.mjs");
|
|
67
|
+
const client = await PlayClient.create({ keyPath: config.google.serviceAccountKey, packageName: config.google.packageName, dryRun });
|
|
68
|
+
const { run } = await import(`./play/commands/${spec.mod}.mjs`);
|
|
69
|
+
return { ok: (await run(config, client)) !== false, planned: [] };
|
|
70
|
+
}
|
|
71
|
+
const client = spec.client ? new Client({ keyId: config.keyId, issuerId: config.issuerId, keyPath: config.keyPath, keyContent: config.keyContent, dryRun }) : null;
|
|
72
|
+
const { run } = await import(`./commands/${spec.mod}.mjs`);
|
|
73
|
+
// altool authenticates on its own rather than through our JWT, so it needs the raw ids.
|
|
74
|
+
const ok = await run(config, client, spec.credentials ? { keyId: config.keyId, issuerId: config.issuerId } : undefined);
|
|
75
|
+
return { ok: ok !== false, planned: client?.planned ?? [] };
|
|
76
|
+
});
|
|
63
77
|
}
|
|
78
|
+
|
|
79
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware: a consumer's own logic around every command.
|
|
3
|
+
*
|
|
4
|
+
* THIS FILE IS IDENTICAL IN vydanne AND zdymak. Both tools take the same `middleware` config key and give
|
|
5
|
+
* it the same semantics, so a check written for one reads the same in the other. Change both together.
|
|
6
|
+
*
|
|
7
|
+
* // vydanne.config.mjs or zdymak.config.mjs
|
|
8
|
+
* export default {
|
|
9
|
+
* middleware: [
|
|
10
|
+
* // a function: runs for every command
|
|
11
|
+
* async (ctx, next) => {
|
|
12
|
+
* console.log(`about to run ${ctx.command}`);
|
|
13
|
+
* const result = await next(); // the rest of the chain, then the command itself
|
|
14
|
+
* console.log('done');
|
|
15
|
+
* return result; // return nothing and the command's own result is kept
|
|
16
|
+
* },
|
|
17
|
+
* // an object: the same function, limited to the commands (and stores) it names
|
|
18
|
+
* {
|
|
19
|
+
* name: 'release-gate',
|
|
20
|
+
* commands: ['prerelease'],
|
|
21
|
+
* stores: ['google'], // vydanne only
|
|
22
|
+
* run(ctx, next) {
|
|
23
|
+
* if (ctx.apply && !receiptFor(ctx)) ctx.fail('no receipt for this bundle');
|
|
24
|
+
* return next();
|
|
25
|
+
* },
|
|
26
|
+
* },
|
|
27
|
+
* ],
|
|
28
|
+
* };
|
|
29
|
+
*
|
|
30
|
+
* Order is the array order: the first entry is the outermost. Code before `await next()` runs on the way in and
|
|
31
|
+
* may REFUSE (`ctx.fail(message)` stops the command and exits non-zero); code after it runs on the way out and
|
|
32
|
+
* sees the command's result. An entry that never calls `next()` skips the command, and says so. `ctx` carries
|
|
33
|
+
* what the tool knows (see each tool's docs); `ctx.config` is the live config, so a change made on the way in
|
|
34
|
+
* is seen by the command.
|
|
35
|
+
*
|
|
36
|
+
* What middleware is NOT: a plugin system for new commands, and not a sandbox. It runs with the full power of
|
|
37
|
+
* the config file it lives in, which is already arbitrary code.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Thrown by `ctx.fail`. The CLIs print it as a refusal and exit 1, with no stack trace. */
|
|
41
|
+
export class MiddlewareRefusal extends Error {
|
|
42
|
+
constructor(reason, middleware) {
|
|
43
|
+
super(`refused by middleware '${middleware}': ${reason}`);
|
|
44
|
+
this.name = 'MiddlewareRefusal';
|
|
45
|
+
this.middleware = middleware;
|
|
46
|
+
this.reason = reason;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function names(value, label, where) {
|
|
51
|
+
if (value == null) return null;
|
|
52
|
+
const list = Array.isArray(value) ? value : [value];
|
|
53
|
+
if (!list.every((x) => typeof x === 'string' && x)) {
|
|
54
|
+
throw new Error(`${where}: '${label}' must be a string or an array of strings`);
|
|
55
|
+
}
|
|
56
|
+
return list;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate the `middleware` config value and return `{ name, commands, stores, run }` entries.
|
|
61
|
+
* A bad entry is a config error at load time, never a surprise halfway through a release.
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeMiddleware(list, tool = 'tool') {
|
|
64
|
+
if (list == null) return [];
|
|
65
|
+
if (!Array.isArray(list)) {
|
|
66
|
+
throw new Error(`${tool}: config 'middleware' must be an array of functions or { run } objects, got ${typeof list}`);
|
|
67
|
+
}
|
|
68
|
+
return list.map((entry, i) => {
|
|
69
|
+
const where = `${tool}: config 'middleware'[${i}]`;
|
|
70
|
+
const fallback = `middleware[${i}]`;
|
|
71
|
+
if (typeof entry === 'function') return { name: entry.name || fallback, commands: null, stores: null, run: entry };
|
|
72
|
+
if (entry && typeof entry === 'object' && typeof entry.run === 'function') {
|
|
73
|
+
return {
|
|
74
|
+
name: String(entry.name ?? (entry.run.name || fallback)),
|
|
75
|
+
commands: names(entry.commands, 'commands', where),
|
|
76
|
+
stores: names(entry.stores, 'stores', where),
|
|
77
|
+
run: entry.run,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`${where} must be a function (ctx, next) or an object with a run(ctx, next) function`);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run `final` (the command) inside the middleware chain and return its result.
|
|
86
|
+
*
|
|
87
|
+
* `ctx` is the tool's context object; `log`, `warn` and `fail` are added to it here, so an entry can say
|
|
88
|
+
* `ctx.fail('...')` without importing anything.
|
|
89
|
+
*/
|
|
90
|
+
export async function runMiddleware(list, ctx, final) {
|
|
91
|
+
const chain = normalizeMiddleware(list, ctx.tool);
|
|
92
|
+
if (chain.length === 0) return final();
|
|
93
|
+
|
|
94
|
+
const current = { name: 'middleware' };
|
|
95
|
+
ctx.log = (...args) => console.log(`[${current.name}]`, ...args);
|
|
96
|
+
ctx.warn = (...args) => console.warn(`[${current.name}]`, ...args);
|
|
97
|
+
ctx.fail = (reason) => {
|
|
98
|
+
throw new MiddlewareRefusal(reason, current.name);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const dispatch = async (i) => {
|
|
102
|
+
if (i === chain.length) return final();
|
|
103
|
+
const mw = chain[i];
|
|
104
|
+
if (mw.commands && !mw.commands.includes(ctx.command)) return dispatch(i + 1);
|
|
105
|
+
if (mw.stores && ctx.store && !mw.stores.includes(ctx.store)) return dispatch(i + 1);
|
|
106
|
+
|
|
107
|
+
let called = false;
|
|
108
|
+
let inner;
|
|
109
|
+
const next = async () => {
|
|
110
|
+
if (called) throw new Error(`middleware '${mw.name}' called next() twice`);
|
|
111
|
+
called = true;
|
|
112
|
+
inner = await dispatch(i + 1);
|
|
113
|
+
current.name = mw.name; // control is back with this entry, so its messages carry its name again
|
|
114
|
+
return inner;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
current.name = mw.name;
|
|
118
|
+
const result = await mw.run(ctx, next);
|
|
119
|
+
if (!called) console.log(` (${ctx.command} skipped: middleware '${mw.name}' did not call next())`);
|
|
120
|
+
// An entry that only guards on the way in has nothing to return; the command's own result is kept.
|
|
121
|
+
return result === undefined ? inner : result;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return dispatch(0);
|
|
125
|
+
}
|
package/types/index.d.ts
CHANGED
|
@@ -355,8 +355,62 @@ export interface VydanneConfig {
|
|
|
355
355
|
* a wrong commit as confidently as a right one.
|
|
356
356
|
*/
|
|
357
357
|
buildNumberOffset?: number;
|
|
358
|
+
/**
|
|
359
|
+
* The app's own logic around every store command, in the order given (the first is outermost). Each entry
|
|
360
|
+
* is a function `(ctx, next)` or an object `{ name, commands, stores, run }` that limits it. Code before
|
|
361
|
+
* `await next()` runs first and may refuse with `ctx.fail(reason)`; code after it sees the command's
|
|
362
|
+
* result. Not run for `auth`, `locales`, `version` or help. See the README, "Middleware".
|
|
363
|
+
*/
|
|
364
|
+
middleware?: Middleware[];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** What a middleware entry is handed. `config` is live: a change made before `next()` is seen by the command. */
|
|
368
|
+
export interface MiddlewareContext {
|
|
369
|
+
tool: "vydanne";
|
|
370
|
+
version: string;
|
|
371
|
+
/** The command, e.g. `prerelease`. */
|
|
372
|
+
command: string;
|
|
373
|
+
store: Store;
|
|
374
|
+
/** `--apply` (or VYDANNE_COMMIT=1) was given: the command will write to the store. */
|
|
375
|
+
apply: boolean;
|
|
376
|
+
/** The command writes and `apply` is off, so it will only validate. */
|
|
377
|
+
dryRun: boolean;
|
|
378
|
+
/** The command is one that can write to a store at all. */
|
|
379
|
+
writes: boolean;
|
|
380
|
+
/** The command-line arguments after the command name; empty when run through `runCommand`. */
|
|
381
|
+
argv: string[];
|
|
382
|
+
config: ResolvedConfig;
|
|
383
|
+
cwd: string;
|
|
384
|
+
env: Record<string, string | undefined>;
|
|
385
|
+
log(...args: unknown[]): void;
|
|
386
|
+
warn(...args: unknown[]): void;
|
|
387
|
+
/** Refuse: stops the command, prints the reason and exits 1. */
|
|
388
|
+
fail(reason: string): never;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export type MiddlewareFunction = (ctx: MiddlewareContext, next: () => Promise<unknown>) => unknown;
|
|
392
|
+
|
|
393
|
+
export interface MiddlewareObject {
|
|
394
|
+
/** Shown in messages; defaults to the function's own name. */
|
|
395
|
+
name?: string;
|
|
396
|
+
/** Run only for these commands. */
|
|
397
|
+
commands?: string | string[];
|
|
398
|
+
/** Run only for these stores. */
|
|
399
|
+
stores?: Store | Store[];
|
|
400
|
+
run: MiddlewareFunction;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export type Middleware = MiddlewareFunction | MiddlewareObject;
|
|
404
|
+
|
|
405
|
+
/** Thrown by `ctx.fail`. The CLI prints it as a refusal and exits 1. */
|
|
406
|
+
export declare class MiddlewareRefusal extends Error {
|
|
407
|
+
middleware: string;
|
|
408
|
+
reason: string;
|
|
358
409
|
}
|
|
359
410
|
|
|
411
|
+
export declare function normalizeMiddleware(list: unknown, tool?: string): Array<Required<Omit<MiddlewareObject, "commands" | "stores">> & { commands: string[] | null; stores: string[] | null }>;
|
|
412
|
+
export declare function runMiddleware<T>(list: unknown, ctx: Record<string, unknown> & { tool: string; command: string }, final: () => Promise<T>): Promise<T>;
|
|
413
|
+
|
|
360
414
|
/** Thin ASC REST client (native fetch + ES256 JWT). */
|
|
361
415
|
export declare class Client {
|
|
362
416
|
constructor(opts: { keyId: string; issuerId: string; keyPath?: string; keyContent?: string; dryRun?: boolean });
|
|
@@ -416,6 +470,8 @@ export declare function runCommand(
|
|
|
416
470
|
configPath?: string;
|
|
417
471
|
store?: Store;
|
|
418
472
|
apply?: boolean;
|
|
473
|
+
/** Shown to middleware as `ctx.argv`. */
|
|
474
|
+
argv?: string[];
|
|
419
475
|
},
|
|
420
476
|
): Promise<{ ok: boolean; planned: Array<{ method: string; path: string; attributes: Record<string, unknown> }> }>;
|
|
421
477
|
|