@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.
@@ -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;
@@ -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 mode contract: `assets verify-delivery --json`
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.',
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitGitSha": "84316fe7a7da51e1b448d8935240a5f60aaf4adc",
2
+ "kitGitSha": "ca727bc6ebd8c1ecb7515ceb76f48ea5e913ab82",
3
3
  "kitDirty": false,
4
- "packageVersion": "0.39.0"
4
+ "packageVersion": "0.41.0"
5
5
  }