@mpgd/cli 0.39.0 → 0.41.0
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/dist/asset-pack-build.js +4 -1
- package/dist/asset-pack-native-audit.d.ts +5 -0
- package/dist/asset-pack-native-audit.js +137 -0
- package/dist/asset-pack-publish.d.ts +43 -0
- package/dist/asset-pack-publish.js +309 -0
- package/dist/asset-pack-target.d.ts +42 -0
- package/dist/asset-pack-target.js +251 -0
- package/dist/game-acceptance.d.ts +4 -0
- package/dist/game-acceptance.js +49 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +127 -6
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +3404 -621
- package/dist/performance-acceptance.d.ts +56 -0
- package/dist/performance-acceptance.js +269 -0
- package/package.json +15 -10
- package/templates/phaser-game/agent/acceptance.md +7 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { copyFile, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, writeFile, } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { validatePhaserPackBuildConfig, validatePhaserPackDeliveryManifest, } from '@mpgd/phaser-assets/pack-format';
|
|
7
|
+
import { assertAssetPackTargetPolicy, resolveAssetPackLocations, } from '@mpgd/target-config/asset-packs';
|
|
8
|
+
import { buildAssetPacks } from './asset-pack-build.js';
|
|
9
|
+
import { verifyAssetPackDelivery } from './asset-pack-verify.js';
|
|
10
|
+
export const assetPackTargetNamespace = 'mpgd-asset-packs';
|
|
11
|
+
export const assetPackTargetManifestName = 'asset-pack-delivery.json';
|
|
12
|
+
export const assetPackTargetPolicyName = 'target-policy.json';
|
|
13
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
14
|
+
async function fileDigest(path) {
|
|
15
|
+
const hash = createHash('sha256');
|
|
16
|
+
for await (const chunk of createReadStream(path)) {
|
|
17
|
+
hash.update(chunk);
|
|
18
|
+
}
|
|
19
|
+
return hash.digest('hex');
|
|
20
|
+
}
|
|
21
|
+
function inside(parent, child) {
|
|
22
|
+
const step = relative(parent, child);
|
|
23
|
+
return step === '' || (!isAbsolute(step) && step !== '..' && !step.startsWith(`..${sep}`));
|
|
24
|
+
}
|
|
25
|
+
async function noSymlink(path) {
|
|
26
|
+
let current = resolve(path);
|
|
27
|
+
while (true) {
|
|
28
|
+
const stat = await lstat(current).catch((error) => {
|
|
29
|
+
if (error.code === 'ENOENT') {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
throw error;
|
|
33
|
+
});
|
|
34
|
+
if (stat?.isSymbolicLink()) {
|
|
35
|
+
// macOS exposes its system temporary directories through these fixed
|
|
36
|
+
// aliases. Accept only their canonical OS targets, never user links.
|
|
37
|
+
const systemAlias = process.platform === 'darwin'
|
|
38
|
+
&& ((current === '/var' && await realpath(current) === '/private/var')
|
|
39
|
+
|| (current === '/tmp' && await realpath(current) === '/private/tmp'));
|
|
40
|
+
if (!systemAlias) {
|
|
41
|
+
throw new Error(`Asset target path must not traverse a symbolic link: ${path}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const parent = dirname(current);
|
|
45
|
+
if (parent === current) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
current = parent;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function canonicalOutput(path) {
|
|
52
|
+
let ancestor = path;
|
|
53
|
+
while (true) {
|
|
54
|
+
try {
|
|
55
|
+
const canonical = await realpath(ancestor);
|
|
56
|
+
return resolve(canonical, relative(ancestor, path));
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code !== 'ENOENT') {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
const parent = dirname(ancestor);
|
|
63
|
+
if (parent === ancestor) {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
ancestor = parent;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Stage verified immutable objects into disjoint packaged/remote roots.
|
|
71
|
+
* The output must not exist: a failed attempt never replaces a deployment. */
|
|
72
|
+
export async function stageAssetPackTarget(options) {
|
|
73
|
+
await noSymlink(options.root);
|
|
74
|
+
await noSymlink(options.manifestPath);
|
|
75
|
+
const root = await realpath(resolve(options.root));
|
|
76
|
+
await noSymlink(options.outDir);
|
|
77
|
+
const out = await canonicalOutput(resolve(options.outDir));
|
|
78
|
+
if (inside(root, out) || inside(out, root)) {
|
|
79
|
+
throw new Error('Asset target output must be disjoint from the delivery source root');
|
|
80
|
+
}
|
|
81
|
+
if (await lstat(out).catch((error) => {
|
|
82
|
+
if (error.code === 'ENOENT') {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
})) {
|
|
87
|
+
throw new Error('Asset target output already exists; choose a fresh deployment directory');
|
|
88
|
+
}
|
|
89
|
+
const verified = await verifyAssetPackDelivery({
|
|
90
|
+
manifestPath: options.manifestPath,
|
|
91
|
+
root,
|
|
92
|
+
hostLimits: { maxFiles: 100_000, maxTotalBytes: 1024 ** 3 },
|
|
93
|
+
});
|
|
94
|
+
if (!verified.ok) {
|
|
95
|
+
throw new Error(`Asset target source verification failed: ${verified.failures.map((failure) => failure.code).join(', ')}`);
|
|
96
|
+
}
|
|
97
|
+
const manifestBytes = await readFile(options.manifestPath);
|
|
98
|
+
if (digest(manifestBytes) !== verified.manifest.sha256) {
|
|
99
|
+
throw new Error('Delivery manifest changed during target staging');
|
|
100
|
+
}
|
|
101
|
+
const manifest = validatePhaserPackDeliveryManifest(JSON.parse(manifestBytes.toString('utf8')));
|
|
102
|
+
const locations = resolveAssetPackLocations(options.policy, manifest.packs);
|
|
103
|
+
const runtimePolicy = {
|
|
104
|
+
defaultLocation: 'packaged',
|
|
105
|
+
packs: locations,
|
|
106
|
+
...(options.policy.offlineRequired === undefined ? {} : { offlineRequired: [...options.policy.offlineRequired] }),
|
|
107
|
+
...(options.policy.remoteBaseUrl === undefined ? {} : { remoteBaseUrl: options.policy.remoteBaseUrl }),
|
|
108
|
+
...(options.policy.maxPackagedBytes === undefined ? {} : { maxPackagedBytes: options.policy.maxPackagedBytes }),
|
|
109
|
+
};
|
|
110
|
+
const policyBytes = Buffer.from(JSON.stringify(runtimePolicy, null, 2) + '\n');
|
|
111
|
+
const objects = new Map();
|
|
112
|
+
for (const pack of manifest.packs) {
|
|
113
|
+
const files = pack.delivery === 'zip'
|
|
114
|
+
? [pack.archive]
|
|
115
|
+
: pack.assets.flatMap((asset) => asset.files);
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
const location = locations[pack.packId];
|
|
118
|
+
objects.set(`${location}:${file.path}`, {
|
|
119
|
+
path: file.path,
|
|
120
|
+
bytes: file.bytes,
|
|
121
|
+
sha256: file.sha256,
|
|
122
|
+
location,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const planned = [...objects.values()].sort((a, b) => a.location.localeCompare(b.location) || a.path.localeCompare(b.path));
|
|
127
|
+
const packagedAssetBytes = planned.filter((object) => object.location === 'packaged').reduce((sum, object) => sum + object.bytes, 0);
|
|
128
|
+
const packagedBytes = packagedAssetBytes + manifestBytes.length + policyBytes.length;
|
|
129
|
+
if (options.policy.maxPackagedBytes !== undefined && packagedBytes > options.policy.maxPackagedBytes) {
|
|
130
|
+
throw new Error(`Packaged asset namespace exceeds its budget: ${packagedBytes} > ${options.policy.maxPackagedBytes}`);
|
|
131
|
+
}
|
|
132
|
+
await mkdir(dirname(out), { recursive: true });
|
|
133
|
+
const temporary = await mkdtemp(join(dirname(out), '.mpgd-asset-target-'));
|
|
134
|
+
try {
|
|
135
|
+
await mkdir(join(temporary, 'packaged'));
|
|
136
|
+
await mkdir(join(temporary, 'remote'));
|
|
137
|
+
for (const object of planned) {
|
|
138
|
+
const source = join(root, object.path);
|
|
139
|
+
await noSymlink(source);
|
|
140
|
+
const destination = join(temporary, object.location, object.path);
|
|
141
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
142
|
+
await copyFile(source, destination);
|
|
143
|
+
const stat = await lstat(destination);
|
|
144
|
+
if (stat.size !== object.bytes || await fileDigest(destination) !== object.sha256) {
|
|
145
|
+
throw new Error(`Asset object changed during target staging: ${object.path}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
await writeFile(join(temporary, 'packaged', assetPackTargetManifestName), manifestBytes);
|
|
149
|
+
await writeFile(join(temporary, 'packaged', assetPackTargetPolicyName), policyBytes);
|
|
150
|
+
await rename(temporary, out);
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await rm(temporary, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
outDir: out,
|
|
157
|
+
packagedDir: join(out, 'packaged'),
|
|
158
|
+
remoteDir: join(out, 'remote'),
|
|
159
|
+
manifestSha256: verified.manifest.sha256,
|
|
160
|
+
locations,
|
|
161
|
+
packagedAssetBytes,
|
|
162
|
+
packagedBytes,
|
|
163
|
+
remoteBytes: planned.filter((object) => object.location === 'remote').reduce((sum, object) => sum + object.bytes, 0),
|
|
164
|
+
objects: planned,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Build from one target's policy without requiring a kit checkout. */
|
|
168
|
+
export async function assertAssetPackTargetOutput(options) {
|
|
169
|
+
assertAssetPackTargetPolicy(options.policy);
|
|
170
|
+
const configPath = resolve(options.baseDir, options.policy.buildConfig);
|
|
171
|
+
const config = validatePhaserPackBuildConfig(JSON.parse(await readFile(configPath, 'utf8')));
|
|
172
|
+
const sourceRoot = await realpath(resolve(dirname(configPath), config.root));
|
|
173
|
+
await noSymlink(options.outDir);
|
|
174
|
+
const output = await canonicalOutput(resolve(options.outDir));
|
|
175
|
+
if (inside(sourceRoot, output) || inside(output, sourceRoot)) {
|
|
176
|
+
throw new Error('Asset target output must be disjoint from the original asset source root');
|
|
177
|
+
}
|
|
178
|
+
if (inside(output, await realpath(configPath))) {
|
|
179
|
+
throw new Error('Asset target output must not replace its build config');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export async function buildAssetPackTarget(options) {
|
|
183
|
+
await assertAssetPackTargetOutput(options);
|
|
184
|
+
const configPath = resolve(options.baseDir, options.policy.buildConfig);
|
|
185
|
+
const temporary = await mkdtemp(join(tmpdir(), 'mpgd-asset-source-'));
|
|
186
|
+
try {
|
|
187
|
+
const built = buildAssetPacks({
|
|
188
|
+
configPath,
|
|
189
|
+
outDir: join(temporary, 'delivery'),
|
|
190
|
+
});
|
|
191
|
+
return await stageAssetPackTarget({
|
|
192
|
+
manifestPath: built.manifestPath,
|
|
193
|
+
root: built.outDir,
|
|
194
|
+
policy: options.policy,
|
|
195
|
+
outDir: options.outDir,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await rm(temporary, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Verify the real bundle's asset namespace and reject byte-identical remote
|
|
203
|
+
* objects copied elsewhere. This does not inspect transformed/embedded data. */
|
|
204
|
+
export async function assertAssetPackTargetArtifact(report, artifactRoot, namespacePath = assetPackTargetNamespace) {
|
|
205
|
+
const root = resolve(artifactRoot);
|
|
206
|
+
const namespace = resolve(root, namespacePath);
|
|
207
|
+
if (!inside(root, namespace) || namespace === root) {
|
|
208
|
+
throw new Error('Asset namespace must be inside the target artifact');
|
|
209
|
+
}
|
|
210
|
+
const localHashes = new Set(report.objects.filter((object) => object.location === 'packaged').map((object) => object.sha256));
|
|
211
|
+
const remoteHashes = new Set(report.objects.filter((object) => object.location === 'remote' && !localHashes.has(object.sha256)).map((object) => object.sha256));
|
|
212
|
+
let count = 0;
|
|
213
|
+
let namespaceBytes = 0;
|
|
214
|
+
const pending = [root];
|
|
215
|
+
while (pending.length) {
|
|
216
|
+
const directory = pending.pop();
|
|
217
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
218
|
+
const path = join(directory, entry.name);
|
|
219
|
+
if (++count > 100_000 || entry.isSymbolicLink()) {
|
|
220
|
+
throw new Error('Asset artifact inventory exceeds its limit or contains a symbolic link');
|
|
221
|
+
}
|
|
222
|
+
if (entry.isDirectory()) {
|
|
223
|
+
pending.push(path);
|
|
224
|
+
}
|
|
225
|
+
else if (entry.isFile()) {
|
|
226
|
+
if (inside(namespace, path)) {
|
|
227
|
+
namespaceBytes += (await lstat(path)).size;
|
|
228
|
+
}
|
|
229
|
+
if (remoteHashes.has(await fileDigest(path))) {
|
|
230
|
+
throw new Error(`Remote-only asset object is present in the packaged artifact: ${relative(root, path)}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
throw new Error('Asset artifact contains a non-regular entry');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (namespaceBytes !== report.packagedBytes) {
|
|
239
|
+
throw new Error('Packaged asset namespace has extra, missing or modified bytes');
|
|
240
|
+
}
|
|
241
|
+
for (const name of [assetPackTargetManifestName, assetPackTargetPolicyName]) {
|
|
242
|
+
if (await fileDigest(join(namespace, name)) !== await fileDigest(join(report.packagedDir, name))) {
|
|
243
|
+
throw new Error(`Packaged asset metadata changed: ${name}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const object of report.objects.filter((object) => object.location === 'packaged')) {
|
|
247
|
+
if (await fileDigest(join(namespace, object.path)) !== object.sha256) {
|
|
248
|
+
throw new Error(`Packaged asset object changed: ${object.path}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PerformanceAcceptanceEvidence, type PerformanceAcceptanceInput } from './performance-acceptance.js';
|
|
1
2
|
export declare const defaultGameAcceptanceCommandTimeoutMs: number;
|
|
2
3
|
export declare const maximumGameplayE2EReportBytes: number;
|
|
3
4
|
export declare function resolveGameAcceptanceReleaseManifestFile(gameRoot: string, env?: NodeJS.ProcessEnv): string;
|
|
@@ -32,6 +33,7 @@ export interface GameAcceptanceReport {
|
|
|
32
33
|
readonly options: Readonly<Record<string, string | boolean | null>>;
|
|
33
34
|
readonly steps: readonly GameAcceptanceStepResult[];
|
|
34
35
|
readonly evidence: {
|
|
36
|
+
readonly performance?: PerformanceAcceptanceEvidence;
|
|
35
37
|
readonly releaseManifest: {
|
|
36
38
|
readonly file: string;
|
|
37
39
|
readonly found: boolean;
|
|
@@ -57,6 +59,8 @@ export interface RunGameAcceptanceInput {
|
|
|
57
59
|
readonly reportDir: string;
|
|
58
60
|
readonly releaseManifestFile?: string;
|
|
59
61
|
readonly gameplayE2EReportFile?: string;
|
|
62
|
+
/** Optional fresh performance collection and consumer-authored budget. */
|
|
63
|
+
readonly performance?: PerformanceAcceptanceInput;
|
|
60
64
|
readonly requireGameplayE2EReport?: boolean;
|
|
61
65
|
/** Step that must replace any previous gameplay report before evidence is accepted. */
|
|
62
66
|
readonly gameplayE2EStepId?: string;
|
package/dist/game-acceptance.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { escapeMarkdownInline, escapeMarkdownTable, formatDuration, formatError, readBoundedUtf8File, relativeOrAbsolute, writeEvidenceReportFiles, } from './evidence-io.js';
|
|
5
5
|
import { collectGameplayE2EPathEvidence, maximumGameplayE2EStates, readGameplayE2EPlan, resolveGameplayE2EPathInsideGameRoot, } from './gameplay-e2e.js';
|
|
6
|
+
import { preparePerformanceAcceptance, readPerformanceAcceptanceEvidence, } from './performance-acceptance.js';
|
|
6
7
|
export const defaultGameAcceptanceCommandTimeoutMs = 30 * 60 * 1_000;
|
|
7
8
|
const defaultGameAcceptanceReleaseManifestFile = 'artifacts/release-manifest.json';
|
|
8
9
|
export const maximumGameplayE2EReportBytes = 1_024 * 1_024;
|
|
@@ -22,10 +23,25 @@ export function runGameAcceptance(input) {
|
|
|
22
23
|
const commandRunner = input.commandRunner
|
|
23
24
|
?? ((step) => runAcceptanceCommand(step, input.env ?? process.env, commandTimeoutMs));
|
|
24
25
|
const gameRoot = path.resolve(input.gameRoot);
|
|
26
|
+
const reportDir = path.resolve(input.reportDir);
|
|
27
|
+
const jsonFile = path.join(reportDir, 'acceptance-report.json');
|
|
28
|
+
const markdownFile = path.join(reportDir, 'acceptance-report.md');
|
|
25
29
|
const startedAtMs = now();
|
|
26
30
|
const results = [];
|
|
27
31
|
let failed = false;
|
|
28
32
|
let gameplayE2EReportFileToReplace;
|
|
33
|
+
const performance = input.performance === undefined
|
|
34
|
+
? undefined
|
|
35
|
+
: preparePerformanceAcceptance(gameRoot, input.performance);
|
|
36
|
+
if (performance !== undefined && [jsonFile, markdownFile].some((file) => file === performance.reportFile || file === path.resolve(gameRoot, performance.budgetEvidence.file))) {
|
|
37
|
+
throw new Error('Performance budget and report must not overwrite acceptance handoff files.');
|
|
38
|
+
}
|
|
39
|
+
let performanceStartedAtMs;
|
|
40
|
+
if (input.performance !== undefined
|
|
41
|
+
&& (input.steps.filter((step) => step.id === input.performance?.stepId).length !== 1
|
|
42
|
+
|| input.steps.some((step) => step.id === input.performance?.stepId && step.skipReason !== undefined))) {
|
|
43
|
+
throw new Error('Performance evidence needs exactly one runnable collection step.');
|
|
44
|
+
}
|
|
29
45
|
if (input.gameplayE2EStepId !== undefined) {
|
|
30
46
|
if (input.gameplayE2EReportFile === undefined) {
|
|
31
47
|
throw new Error('A gameplay E2E evidence step needs a report file.');
|
|
@@ -50,6 +66,16 @@ export function runGameAcceptance(input) {
|
|
|
50
66
|
continue;
|
|
51
67
|
}
|
|
52
68
|
}
|
|
69
|
+
if (!failed && step.id === input.performance?.stepId && performance !== undefined) {
|
|
70
|
+
try {
|
|
71
|
+
rmSync(resolveGameplayE2EPathInsideGameRoot(gameRoot, performance.reportFile, 'Performance report'), { force: true });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
results.push(failedStepResult(step, now(), formatError(error)));
|
|
75
|
+
failed = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
53
79
|
if (failed) {
|
|
54
80
|
results.push(skippedStepResult(step, now(), 'A previous acceptance step failed.'));
|
|
55
81
|
continue;
|
|
@@ -70,6 +96,9 @@ export function runGameAcceptance(input) {
|
|
|
70
96
|
const cwd = path.resolve(runnableStep.cwd);
|
|
71
97
|
const stepStartedAtMs = now();
|
|
72
98
|
const startedAt = new Date(stepStartedAtMs).toISOString();
|
|
99
|
+
if (step.id === input.performance?.stepId) {
|
|
100
|
+
performanceStartedAtMs = stepStartedAtMs;
|
|
101
|
+
}
|
|
73
102
|
log(`[mpgd:accept] ${step.label}`);
|
|
74
103
|
let commandResult;
|
|
75
104
|
try {
|
|
@@ -103,11 +132,23 @@ export function runGameAcceptance(input) {
|
|
|
103
132
|
|| !gameplayE2E.found
|
|
104
133
|
|| gameplayE2E.parseError !== null
|
|
105
134
|
|| gameplayE2E.validationError !== null);
|
|
135
|
+
const performanceEvidence = performance === undefined
|
|
136
|
+
? undefined
|
|
137
|
+
: readPerformanceAcceptanceEvidence({
|
|
138
|
+
gameRoot,
|
|
139
|
+
prepared: performance,
|
|
140
|
+
startedAtMs: performanceStartedAtMs,
|
|
141
|
+
finishedAtMs: now(),
|
|
142
|
+
profile: typeof input.options.profile === 'string' ? input.options.profile : undefined,
|
|
143
|
+
releaseManifestFile: input.releaseManifestFile,
|
|
144
|
+
});
|
|
145
|
+
const performanceFailed = performanceEvidence?.validationError !== undefined
|
|
146
|
+
&& performanceEvidence.validationError !== null;
|
|
106
147
|
const finishedAtMs = now();
|
|
107
148
|
const report = {
|
|
108
149
|
schemaVersion: 1,
|
|
109
150
|
generatedAt: new Date(finishedAtMs).toISOString(),
|
|
110
|
-
status: failed || evidenceFailed ? 'failed' : 'passed',
|
|
151
|
+
status: failed || evidenceFailed || performanceFailed ? 'failed' : 'passed',
|
|
111
152
|
gameRoot,
|
|
112
153
|
durationMs: Math.max(0, finishedAtMs - startedAtMs),
|
|
113
154
|
options: input.options,
|
|
@@ -115,11 +156,9 @@ export function runGameAcceptance(input) {
|
|
|
115
156
|
evidence: {
|
|
116
157
|
releaseManifest,
|
|
117
158
|
gameplayE2E,
|
|
159
|
+
...(performanceEvidence === undefined ? {} : { performance: performanceEvidence }),
|
|
118
160
|
},
|
|
119
161
|
};
|
|
120
|
-
const reportDir = path.resolve(input.reportDir);
|
|
121
|
-
const jsonFile = path.join(reportDir, 'acceptance-report.json');
|
|
122
|
-
const markdownFile = path.join(reportDir, 'acceptance-report.md');
|
|
123
162
|
mkdirSync(reportDir, { recursive: true });
|
|
124
163
|
writeEvidenceReportFiles({
|
|
125
164
|
jsonFile,
|
|
@@ -180,6 +219,12 @@ export function renderGameAcceptanceMarkdown(report) {
|
|
|
180
219
|
else {
|
|
181
220
|
lines.push(`- Gameplay E2E report: ${escapeMarkdownInline(report.evidence.gameplayE2E.file)}`);
|
|
182
221
|
}
|
|
222
|
+
if (report.evidence.performance !== undefined) {
|
|
223
|
+
const performance = report.evidence.performance;
|
|
224
|
+
lines.push('', '## Performance Evidence', '', `- Report: ${escapeMarkdownInline(performance.file)}`, `- Budget: ${escapeMarkdownInline(performance.budget.file)} (${performance.budget.sha256})`, performance.validationError === null
|
|
225
|
+
? '- Budget validation passed.'
|
|
226
|
+
: `- Validation failed: ${escapeMarkdownInline(performance.validationError)}`);
|
|
227
|
+
}
|
|
183
228
|
return `${lines.join('\n')}\n`;
|
|
184
229
|
}
|
|
185
230
|
function runAcceptanceCommand(step, env, timeoutMs) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, maximumGameplayE2EReportBytes, type GameAcceptanceCommandResult, type GameAcceptanceCommandRunner, type GameAcceptanceReport, type GameAcceptanceStatus, type GameAcceptanceStep, type GameAcceptanceStepResult, type GameAcceptanceStepStatus, type RunGameAcceptanceInput, type RunGameAcceptanceResult, } from './game-acceptance.js';
|
|
2
|
+
export { maximumPerformanceEvidenceBytes, preparePerformanceAcceptance, readPerformanceAcceptanceEvidence, validatePerformanceBudget, type PerformanceAcceptanceEvidence, type PerformanceAcceptanceInput, type PerformanceBudget, type PerformanceBudgetProfile, type PerformanceMetricBudget, type PreparedPerformanceAcceptance, } from './performance-acceptance.js';
|
|
2
3
|
export { applyCapacitorShellStarter, materializeCapacitorShellStarter, planCapacitorShellStarter, type CapacitorShellCommandRunner, type CapacitorShellStarterInput, type CapacitorShellStarterPlan, } from './capacitor-shell-starter.js';
|
|
3
4
|
export { ReleaseProcessError, runReleaseProcess, type ReleaseProcessInput, type ReleaseProcessResult, } from './deploy-process.js';
|
|
4
5
|
export { installPinnedReleaseDependencies, pinNativeDeploymentPlan, preparePinnedReleaseWorkspace, runPinnedNativeBuild, withPinnedReleaseWorkspace, type PinnedNativeBuildResult, type PinnedReleaseInput, type PinnedReleaseWorkspace, } from './release-workspace.js';
|
package/dist/index.js
CHANGED
|
@@ -9,13 +9,16 @@ import { cli } from 'gunshi';
|
|
|
9
9
|
import { assertPlatformVersionLedger } from '@mpgd/target-config';
|
|
10
10
|
import { buildAssetPacks } from './asset-pack-build.js';
|
|
11
11
|
import { verifyAssetPackDelivery } from './asset-pack-verify.js';
|
|
12
|
+
import { buildAssetPackTarget } from './asset-pack-target.js';
|
|
13
|
+
import { publishAssetPacks } from './asset-pack-publish.js';
|
|
14
|
+
import { assertAssetPackTargetPolicy } from '@mpgd/target-config/asset-packs';
|
|
12
15
|
import { materializeCapacitorShellStarter, planCapacitorShellStarter, } from './capacitor-shell-starter.js';
|
|
13
16
|
import { doctorNativeDeployment, initializeDeployConfig, parseDeployTargets, planNativeDeployment, readNativeDeploymentPlan, writeNativeDeploymentPlan, } from './deploy-planning.js';
|
|
14
17
|
import { initializeNativeDeployWorkflow } from './native-deploy-workflow.js';
|
|
15
18
|
import { normalizeConfiguredBuildTargets, normalizeBuildTarget as normalizeConfiguredTargetName, supportedBuildTargets, } from './build-targets.js';
|
|
16
19
|
import { escapeMarkdownInline as escapeEvidenceMarkdownInline, writeEvidenceReportFiles, } from './evidence-io.js';
|
|
17
20
|
import { defaultGameAcceptanceCommandTimeoutMs, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, } from './game-acceptance.js';
|
|
18
|
-
import { resolveGameplayE2EReportFile } from './gameplay-e2e.js';
|
|
21
|
+
import { resolveGameplayE2EPathInsideGameRoot, resolveGameplayE2EReportFile, } from './gameplay-e2e.js';
|
|
19
22
|
import { cloudflarePagesDeploymentProfiles, hostedPwaDeploymentHosts, verifyHostedPwaDeployment, } from './hosted-pwa-deployment.js';
|
|
20
23
|
import { createMicrosoftStorePackageAcceptanceRuntime, runMicrosoftStorePackageAcceptance, } from './microsoft-store-package-acceptance.js';
|
|
21
24
|
import { runMicrosoftStorePackageGeneration } from './microsoft-store-package-generation.js';
|
|
@@ -28,6 +31,7 @@ import { readStoreCredential, runNativeDeployment } from './native-deploy-run.js
|
|
|
28
31
|
import { submitRecordedNativeTarget } from './native-deploy-submission.js';
|
|
29
32
|
import { readNativeReleaseStatus as readNativeReleaseStatusForCli } from './release-state.js';
|
|
30
33
|
export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, maximumGameplayE2EReportBytes, } from './game-acceptance.js';
|
|
34
|
+
export { maximumPerformanceEvidenceBytes, preparePerformanceAcceptance, readPerformanceAcceptanceEvidence, validatePerformanceBudget, } from './performance-acceptance.js';
|
|
31
35
|
export { applyCapacitorShellStarter, materializeCapacitorShellStarter, planCapacitorShellStarter, } from './capacitor-shell-starter.js';
|
|
32
36
|
export { ReleaseProcessError, runReleaseProcess, } from './deploy-process.js';
|
|
33
37
|
export { installPinnedReleaseDependencies, pinNativeDeploymentPlan, preparePinnedReleaseWorkspace, runPinnedNativeBuild, withPinnedReleaseWorkspace, } from './release-workspace.js';
|
|
@@ -108,6 +112,7 @@ const acceptanceStepIds = {
|
|
|
108
112
|
targetBuild: 'target-build',
|
|
109
113
|
targetSmoke: 'target-smoke',
|
|
110
114
|
gameplayE2E: 'gameplay-e2e',
|
|
115
|
+
performance: 'performance',
|
|
111
116
|
};
|
|
112
117
|
/** Whether the last run reported a deadline failure that leaves
|
|
113
118
|
* uncancellable filesystem work pending: the standalone binary forces its
|
|
@@ -146,7 +151,7 @@ export async function runMpgdCli(args) {
|
|
|
146
151
|
await cli([...args], entryCommand, {
|
|
147
152
|
name: 'mpgd',
|
|
148
153
|
version: cliVersion,
|
|
149
|
-
// Machine-readable
|
|
154
|
+
// Machine-readable asset reports and upgrade plans with --json
|
|
150
155
|
// owns stdout entirely — exactly one JSON document, parseable with
|
|
151
156
|
// JSON.parse(stdout) and nothing else. The framework's banner is
|
|
152
157
|
// suppressed for that command, and framework-level argument errors
|
|
@@ -155,7 +160,7 @@ export async function runMpgdCli(args) {
|
|
|
155
160
|
// partial document. Every other command keeps the default rendering.
|
|
156
161
|
renderHeader: async (ctx) => {
|
|
157
162
|
const values = ctx.values;
|
|
158
|
-
if ((ctx.name === 'verify-delivery' || ctx.name === 'upgrade') && values.json === true) {
|
|
163
|
+
if ((ctx.name === 'verify-delivery' || ctx.name === 'stage-target' || ctx.name === 'publish-s3' || ctx.name === 'upgrade') && values.json === true) {
|
|
159
164
|
return '';
|
|
160
165
|
}
|
|
161
166
|
const title = ctx.env.description || ctx.env.name || '';
|
|
@@ -166,7 +171,7 @@ export async function runMpgdCli(args) {
|
|
|
166
171
|
renderValidationErrors: async (ctx, error) => {
|
|
167
172
|
const messages = error.errors.map((entry) => String(entry.message)).join('\n');
|
|
168
173
|
const values = ctx.values;
|
|
169
|
-
if ((ctx.name === 'verify-delivery' || ctx.name === 'upgrade') && values.json === true) {
|
|
174
|
+
if ((ctx.name === 'verify-delivery' || ctx.name === 'stage-target' || ctx.name === 'publish-s3' || ctx.name === 'upgrade') && values.json === true) {
|
|
170
175
|
process.stderr.write(`${messages}\n`);
|
|
171
176
|
return '';
|
|
172
177
|
}
|
|
@@ -722,6 +727,18 @@ const gameCommand = defineI18n({
|
|
|
722
727
|
required: false,
|
|
723
728
|
description: 'Skip the game-owned playtest package script.',
|
|
724
729
|
},
|
|
730
|
+
performance: {
|
|
731
|
+
type: 'boolean', required: false,
|
|
732
|
+
description: 'Collect and validate fresh foreground performance evidence.',
|
|
733
|
+
},
|
|
734
|
+
'performance-script': {
|
|
735
|
+
type: 'string', required: false, default: 'performance:e2e',
|
|
736
|
+
description: 'Game-owned performance collection package script.',
|
|
737
|
+
},
|
|
738
|
+
'performance-budget': {
|
|
739
|
+
type: 'string', required: false, default: 'agent/performance.budget.json',
|
|
740
|
+
description: 'Consumer-authored performance budget JSON inside the game root.',
|
|
741
|
+
},
|
|
725
742
|
'skip-gameplay-e2e': {
|
|
726
743
|
type: 'boolean',
|
|
727
744
|
required: false,
|
|
@@ -761,6 +778,9 @@ const gameCommand = defineI18n({
|
|
|
761
778
|
skipGraph: ctx.values['skip-graph'] === true,
|
|
762
779
|
skipPlaytest: ctx.values['skip-playtest'] === true,
|
|
763
780
|
skipGameplayE2E: ctx.values['skip-gameplay-e2e'] === true,
|
|
781
|
+
performance: ctx.values.performance === true,
|
|
782
|
+
performanceScript: readOptionalString(ctx.values['performance-script']) ?? 'performance:e2e',
|
|
783
|
+
performanceBudget: readOptionalString(ctx.values['performance-budget']) ?? 'agent/performance.budget.json',
|
|
764
784
|
skipTargetBuild: ctx.values['skip-target-build'] === true,
|
|
765
785
|
skipTargetSmoke: ctx.values['skip-target-smoke'] === true,
|
|
766
786
|
});
|
|
@@ -920,7 +940,7 @@ function acceptGame(input) {
|
|
|
920
940
|
const packageManager = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
|
921
941
|
const gameplayE2EEnabled = !input.skipGameplayE2E
|
|
922
942
|
&& gamePackage.scripts?.[input.gameplayScript] !== undefined;
|
|
923
|
-
const gameplayE2ETargets = gameplayE2EEnabled
|
|
943
|
+
const gameplayE2ETargets = gameplayE2EEnabled || input.performance
|
|
924
944
|
? parseTargetList(input.targets, {
|
|
925
945
|
...process.env,
|
|
926
946
|
MPGD_PLATFORM_TARGETS_FILE: targetsFile,
|
|
@@ -1011,12 +1031,30 @@ function acceptGame(input) {
|
|
|
1011
1031
|
skipped: input.skipGameplayE2E,
|
|
1012
1032
|
}),
|
|
1013
1033
|
];
|
|
1034
|
+
const performanceReportFile = input.performance
|
|
1035
|
+
? resolveGameplayE2EPathInsideGameRoot(gameRoot, readOptionalString(process.env.MPGD_PERFORMANCE_REPORT_FILE) ?? 'artifacts/performance/report.json', 'Performance report')
|
|
1036
|
+
: undefined;
|
|
1037
|
+
if (input.performance) {
|
|
1038
|
+
steps.push(packageScriptAcceptanceStep({
|
|
1039
|
+
id: acceptanceStepIds.performance,
|
|
1040
|
+
label: 'Foreground performance collection',
|
|
1041
|
+
script: input.performanceScript,
|
|
1042
|
+
gameRoot,
|
|
1043
|
+
packageManager,
|
|
1044
|
+
scripts: gamePackage.scripts,
|
|
1045
|
+
required: true,
|
|
1046
|
+
}));
|
|
1047
|
+
}
|
|
1014
1048
|
const reportDir = path.resolve(gameRoot, input.reportDir ?? 'artifacts/acceptance');
|
|
1015
1049
|
const releaseManifestFile = resolveGameAcceptanceReleaseManifestFile(gameRoot);
|
|
1016
1050
|
const gameplayE2EReportFile = resolveGameplayE2EReportFile(gameRoot);
|
|
1017
1051
|
const result = runGameAcceptance({
|
|
1018
1052
|
gameRoot,
|
|
1019
1053
|
reportDir,
|
|
1054
|
+
...(performanceReportFile === undefined ? {} : { performance: {
|
|
1055
|
+
budgetFile: input.performanceBudget, reportFile: performanceReportFile,
|
|
1056
|
+
stepId: acceptanceStepIds.performance, targets: gameplayE2ETargets,
|
|
1057
|
+
} }),
|
|
1020
1058
|
...(input.skipTargetBuild
|
|
1021
1059
|
? {}
|
|
1022
1060
|
: { releaseManifestFile }),
|
|
@@ -1035,6 +1073,8 @@ function acceptGame(input) {
|
|
|
1035
1073
|
iosVariant: input.iosVariant ?? null,
|
|
1036
1074
|
playtestScript: input.playtestScript,
|
|
1037
1075
|
gameplayScript: input.gameplayScript,
|
|
1076
|
+
...(input.performance ? { performance: true, performanceScript: input.performanceScript,
|
|
1077
|
+
performanceBudget: input.performanceBudget } : {}),
|
|
1038
1078
|
skipTest: input.skipTest,
|
|
1039
1079
|
skipGraph: input.skipGraph,
|
|
1040
1080
|
skipPlaytest: input.skipPlaytest,
|
|
@@ -1048,12 +1088,16 @@ function acceptGame(input) {
|
|
|
1048
1088
|
env: {
|
|
1049
1089
|
...process.env,
|
|
1050
1090
|
MPGD_KIT_PATH: kitPath,
|
|
1051
|
-
MPGD_ACCEPTANCE_TARGETS: gameplayE2EEnabled
|
|
1091
|
+
MPGD_ACCEPTANCE_TARGETS: gameplayE2EEnabled || input.performance
|
|
1052
1092
|
? gameplayE2ETargets.join(',')
|
|
1053
1093
|
: input.targets,
|
|
1054
1094
|
MPGD_ACCEPTANCE_PROFILE: input.profile,
|
|
1055
1095
|
MPGD_RELEASE_MANIFEST_FILE: releaseManifestFile,
|
|
1056
1096
|
MPGD_GAMEPLAY_E2E_REPORT_FILE: gameplayE2EReportFile,
|
|
1097
|
+
...(performanceReportFile === undefined ? {} : {
|
|
1098
|
+
MPGD_PERFORMANCE_REPORT_FILE: performanceReportFile,
|
|
1099
|
+
MPGD_PERFORMANCE_BUDGET_FILE: path.resolve(gameRoot, input.performanceBudget),
|
|
1100
|
+
}),
|
|
1057
1101
|
},
|
|
1058
1102
|
});
|
|
1059
1103
|
console.info(`[mpgd:accept] JSON report: ${result.jsonFile}`);
|
|
@@ -1195,6 +1239,83 @@ const assetsCommand = defineI18n({
|
|
|
1195
1239
|
ko: '결정적인 에셋 팩 전달 산출물을 빌드합니다.',
|
|
1196
1240
|
}),
|
|
1197
1241
|
subCommands: {
|
|
1242
|
+
'publish-s3': defineI18n({
|
|
1243
|
+
name: 'publish-s3',
|
|
1244
|
+
description: 'Publish immutable remote objects from a staged asset deployment.',
|
|
1245
|
+
resource: commandResource({ en: 'Publish immutable remote objects from a staged asset deployment.', ko: '배치된 에셋 배포의 원격 객체를 변경 불가능한 경로로 게시합니다.' }),
|
|
1246
|
+
args: {
|
|
1247
|
+
deployment: { type: 'string', required: true, description: 'stage-target output with packaged/ and remote/ children.' },
|
|
1248
|
+
endpoint: { type: 'string', required: true, description: 'HTTPS S3 API root endpoint without credentials or query.' },
|
|
1249
|
+
bucket: { type: 'string', required: true, description: 'Existing S3 bucket.' },
|
|
1250
|
+
prefix: { type: 'string', required: true, description: 'Versioned object-key prefix matching the public asset origin.' },
|
|
1251
|
+
region: { type: 'string', default: 'us-east-1', description: 'S3 signing region.' },
|
|
1252
|
+
'dry-run': { type: 'boolean', default: false, description: 'Verify and report without credentials or network calls.' },
|
|
1253
|
+
'allow-http-loopback': { type: 'boolean', default: false, description: 'Explicit development-only HTTP loopback endpoint opt-in.' },
|
|
1254
|
+
'access-key-env': { type: 'string', default: 'MPGD_ASSETS_S3_ACCESS_KEY_ID', description: 'Access-key environment variable name, never its value.' },
|
|
1255
|
+
'secret-key-env': { type: 'string', default: 'MPGD_ASSETS_S3_SECRET_ACCESS_KEY', description: 'Secret-key environment variable name, never its value.' },
|
|
1256
|
+
'session-token-env': { type: 'string', default: 'MPGD_ASSETS_S3_SESSION_TOKEN', description: 'Optional session-token environment variable name.' },
|
|
1257
|
+
'max-object-bytes': { type: 'number', description: 'Object-body cap; default64 MiB, maximum512 MiB.' },
|
|
1258
|
+
'max-total-bytes': { type: 'number', description: 'Total planned object bodies; default1 GiB.' },
|
|
1259
|
+
'max-objects': { type: 'number', description: 'Maximum objects including the immutable manifest snapshot; default10000.' },
|
|
1260
|
+
'request-timeout-ms': { type: 'number', description: 'Per HTTP attempt deadline; default10000 ms.' },
|
|
1261
|
+
'timeout-ms': { type: 'number', description: 'Whole preparation/publication deadline; default120000 ms.' },
|
|
1262
|
+
json: { type: 'boolean', default: false, description: 'Print a single JSON report without sensitive headers or URLs.' },
|
|
1263
|
+
},
|
|
1264
|
+
run: async (ctx) => {
|
|
1265
|
+
const report = await publishAssetPacks({
|
|
1266
|
+
deployment: ctx.values.deployment, endpoint: ctx.values.endpoint, bucket: ctx.values.bucket, prefix: ctx.values.prefix,
|
|
1267
|
+
region: ctx.values.region, dryRun: ctx.values['dry-run'], allowHttpLoopback: ctx.values['allow-http-loopback'],
|
|
1268
|
+
accessKeyEnv: ctx.values['access-key-env'], secretKeyEnv: ctx.values['secret-key-env'], sessionTokenEnv: ctx.values['session-token-env'],
|
|
1269
|
+
...(ctx.values['max-object-bytes'] === undefined ? {} : { maxObjectBytes: ctx.values['max-object-bytes'] }),
|
|
1270
|
+
...(ctx.values['max-total-bytes'] === undefined ? {} : { maxTotalBytes: ctx.values['max-total-bytes'] }),
|
|
1271
|
+
...(ctx.values['max-objects'] === undefined ? {} : { maxObjects: ctx.values['max-objects'] }),
|
|
1272
|
+
...(ctx.values['request-timeout-ms'] === undefined ? {} : { requestTimeoutMs: ctx.values['request-timeout-ms'] }),
|
|
1273
|
+
...(ctx.values['timeout-ms'] === undefined ? {} : { timeoutMs: ctx.values['timeout-ms'] }),
|
|
1274
|
+
});
|
|
1275
|
+
if (ctx.values.json) {
|
|
1276
|
+
console.info(JSON.stringify(report, null, 2));
|
|
1277
|
+
}
|
|
1278
|
+
else {
|
|
1279
|
+
console.info(`${report.dryRun ? 'Planned' : 'Published'} ${report.objects.length} immutable objects; ${report.plannedBytes} planned body bytes, ${report.uploadedBytes} newly uploaded body bytes.`);
|
|
1280
|
+
console.info(`Manifest snapshot: ${report.objects.at(-1)?.key}`);
|
|
1281
|
+
}
|
|
1282
|
+
},
|
|
1283
|
+
}),
|
|
1284
|
+
'stage-target': defineI18n({
|
|
1285
|
+
name: 'stage-target',
|
|
1286
|
+
description: "Build and stage one target's packaged and remote asset packs.",
|
|
1287
|
+
resource: commandResource({
|
|
1288
|
+
en: "Build and stage one target's packaged and remote asset packs.",
|
|
1289
|
+
ko: '한 타깃의 로컬·원격 에셋 팩을 빌드하고 배치합니다.',
|
|
1290
|
+
}),
|
|
1291
|
+
args: {
|
|
1292
|
+
'targets-file': { type: 'string', default: 'mpgd.targets.json', description: 'Game target configuration file.' },
|
|
1293
|
+
target: { type: 'string', required: true, description: 'Deployment target name.' },
|
|
1294
|
+
out: { type: 'string', required: true, description: 'Fresh output directory with packaged/remote children.' },
|
|
1295
|
+
json: { type: 'boolean', default: false, description: 'Print the actual byte and location report as JSON.' },
|
|
1296
|
+
},
|
|
1297
|
+
run: async (ctx) => {
|
|
1298
|
+
const targetsPath = path.resolve(ctx.values['targets-file']);
|
|
1299
|
+
const document = JSON.parse(readFileSync(targetsPath, 'utf8'));
|
|
1300
|
+
if (!Object.hasOwn(document.targets ?? {}, ctx.values.target)) {
|
|
1301
|
+
throw new Error(`Target ${ctx.values.target} is not defined in the target configuration.`);
|
|
1302
|
+
}
|
|
1303
|
+
const policy = document.targets?.[ctx.values.target]?.assetPacks;
|
|
1304
|
+
if (policy === undefined) {
|
|
1305
|
+
throw new Error(`Target ${ctx.values.target} has no assetPacks policy.`);
|
|
1306
|
+
}
|
|
1307
|
+
assertAssetPackTargetPolicy(policy);
|
|
1308
|
+
const report = await buildAssetPackTarget({ policy, baseDir: path.dirname(targetsPath), outDir: ctx.values.out });
|
|
1309
|
+
if (ctx.values.json) {
|
|
1310
|
+
console.info(JSON.stringify(report, null, 2));
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
console.info(`Packaged assets: ${report.packagedAssetBytes} bytes (${report.packagedBytes} including metadata)`);
|
|
1314
|
+
console.info(`Remote objects: ${report.remoteBytes} bytes; manifest sha256:${report.manifestSha256}`);
|
|
1315
|
+
console.info(`Asset pack deployment: ${report.outDir}`);
|
|
1316
|
+
}
|
|
1317
|
+
},
|
|
1318
|
+
}),
|
|
1198
1319
|
'build-packs': defineI18n({
|
|
1199
1320
|
name: 'build-packs',
|
|
1200
1321
|
description: 'Build files or ZIP delivery artifacts for configured packs.',
|