@mpgd/cli 0.39.0 → 0.40.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
+ }
package/dist/index.js CHANGED
@@ -9,6 +9,9 @@ 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';
@@ -146,7 +149,7 @@ export async function runMpgdCli(args) {
146
149
  await cli([...args], entryCommand, {
147
150
  name: 'mpgd',
148
151
  version: cliVersion,
149
- // Machine-readable mode contract: `assets verify-delivery --json`
152
+ // Machine-readable asset reports and upgrade plans with --json
150
153
  // owns stdout entirely — exactly one JSON document, parseable with
151
154
  // JSON.parse(stdout) and nothing else. The framework's banner is
152
155
  // suppressed for that command, and framework-level argument errors
@@ -155,7 +158,7 @@ export async function runMpgdCli(args) {
155
158
  // partial document. Every other command keeps the default rendering.
156
159
  renderHeader: async (ctx) => {
157
160
  const values = ctx.values;
158
- if ((ctx.name === 'verify-delivery' || ctx.name === 'upgrade') && values.json === true) {
161
+ if ((ctx.name === 'verify-delivery' || ctx.name === 'stage-target' || ctx.name === 'publish-s3' || ctx.name === 'upgrade') && values.json === true) {
159
162
  return '';
160
163
  }
161
164
  const title = ctx.env.description || ctx.env.name || '';
@@ -166,7 +169,7 @@ export async function runMpgdCli(args) {
166
169
  renderValidationErrors: async (ctx, error) => {
167
170
  const messages = error.errors.map((entry) => String(entry.message)).join('\n');
168
171
  const values = ctx.values;
169
- if ((ctx.name === 'verify-delivery' || ctx.name === 'upgrade') && values.json === true) {
172
+ if ((ctx.name === 'verify-delivery' || ctx.name === 'stage-target' || ctx.name === 'publish-s3' || ctx.name === 'upgrade') && values.json === true) {
170
173
  process.stderr.write(`${messages}\n`);
171
174
  return '';
172
175
  }
@@ -1195,6 +1198,83 @@ const assetsCommand = defineI18n({
1195
1198
  ko: '결정적인 에셋 팩 전달 산출물을 빌드합니다.',
1196
1199
  }),
1197
1200
  subCommands: {
1201
+ 'publish-s3': defineI18n({
1202
+ name: 'publish-s3',
1203
+ description: 'Publish immutable remote objects from a staged asset deployment.',
1204
+ resource: commandResource({ en: 'Publish immutable remote objects from a staged asset deployment.', ko: '배치된 에셋 배포의 원격 객체를 변경 불가능한 경로로 게시합니다.' }),
1205
+ args: {
1206
+ deployment: { type: 'string', required: true, description: 'stage-target output with packaged/ and remote/ children.' },
1207
+ endpoint: { type: 'string', required: true, description: 'HTTPS S3 API root endpoint without credentials or query.' },
1208
+ bucket: { type: 'string', required: true, description: 'Existing S3 bucket.' },
1209
+ prefix: { type: 'string', required: true, description: 'Versioned object-key prefix matching the public asset origin.' },
1210
+ region: { type: 'string', default: 'us-east-1', description: 'S3 signing region.' },
1211
+ 'dry-run': { type: 'boolean', default: false, description: 'Verify and report without credentials or network calls.' },
1212
+ 'allow-http-loopback': { type: 'boolean', default: false, description: 'Explicit development-only HTTP loopback endpoint opt-in.' },
1213
+ 'access-key-env': { type: 'string', default: 'MPGD_ASSETS_S3_ACCESS_KEY_ID', description: 'Access-key environment variable name, never its value.' },
1214
+ 'secret-key-env': { type: 'string', default: 'MPGD_ASSETS_S3_SECRET_ACCESS_KEY', description: 'Secret-key environment variable name, never its value.' },
1215
+ 'session-token-env': { type: 'string', default: 'MPGD_ASSETS_S3_SESSION_TOKEN', description: 'Optional session-token environment variable name.' },
1216
+ 'max-object-bytes': { type: 'number', description: 'Object-body cap; default64 MiB, maximum512 MiB.' },
1217
+ 'max-total-bytes': { type: 'number', description: 'Total planned object bodies; default1 GiB.' },
1218
+ 'max-objects': { type: 'number', description: 'Maximum objects including the immutable manifest snapshot; default10000.' },
1219
+ 'request-timeout-ms': { type: 'number', description: 'Per HTTP attempt deadline; default10000 ms.' },
1220
+ 'timeout-ms': { type: 'number', description: 'Whole preparation/publication deadline; default120000 ms.' },
1221
+ json: { type: 'boolean', default: false, description: 'Print a single JSON report without sensitive headers or URLs.' },
1222
+ },
1223
+ run: async (ctx) => {
1224
+ const report = await publishAssetPacks({
1225
+ deployment: ctx.values.deployment, endpoint: ctx.values.endpoint, bucket: ctx.values.bucket, prefix: ctx.values.prefix,
1226
+ region: ctx.values.region, dryRun: ctx.values['dry-run'], allowHttpLoopback: ctx.values['allow-http-loopback'],
1227
+ accessKeyEnv: ctx.values['access-key-env'], secretKeyEnv: ctx.values['secret-key-env'], sessionTokenEnv: ctx.values['session-token-env'],
1228
+ ...(ctx.values['max-object-bytes'] === undefined ? {} : { maxObjectBytes: ctx.values['max-object-bytes'] }),
1229
+ ...(ctx.values['max-total-bytes'] === undefined ? {} : { maxTotalBytes: ctx.values['max-total-bytes'] }),
1230
+ ...(ctx.values['max-objects'] === undefined ? {} : { maxObjects: ctx.values['max-objects'] }),
1231
+ ...(ctx.values['request-timeout-ms'] === undefined ? {} : { requestTimeoutMs: ctx.values['request-timeout-ms'] }),
1232
+ ...(ctx.values['timeout-ms'] === undefined ? {} : { timeoutMs: ctx.values['timeout-ms'] }),
1233
+ });
1234
+ if (ctx.values.json) {
1235
+ console.info(JSON.stringify(report, null, 2));
1236
+ }
1237
+ else {
1238
+ console.info(`${report.dryRun ? 'Planned' : 'Published'} ${report.objects.length} immutable objects; ${report.plannedBytes} planned body bytes, ${report.uploadedBytes} newly uploaded body bytes.`);
1239
+ console.info(`Manifest snapshot: ${report.objects.at(-1)?.key}`);
1240
+ }
1241
+ },
1242
+ }),
1243
+ 'stage-target': defineI18n({
1244
+ name: 'stage-target',
1245
+ description: "Build and stage one target's packaged and remote asset packs.",
1246
+ resource: commandResource({
1247
+ en: "Build and stage one target's packaged and remote asset packs.",
1248
+ ko: '한 타깃의 로컬·원격 에셋 팩을 빌드하고 배치합니다.',
1249
+ }),
1250
+ args: {
1251
+ 'targets-file': { type: 'string', default: 'mpgd.targets.json', description: 'Game target configuration file.' },
1252
+ target: { type: 'string', required: true, description: 'Deployment target name.' },
1253
+ out: { type: 'string', required: true, description: 'Fresh output directory with packaged/remote children.' },
1254
+ json: { type: 'boolean', default: false, description: 'Print the actual byte and location report as JSON.' },
1255
+ },
1256
+ run: async (ctx) => {
1257
+ const targetsPath = path.resolve(ctx.values['targets-file']);
1258
+ const document = JSON.parse(readFileSync(targetsPath, 'utf8'));
1259
+ if (!Object.hasOwn(document.targets ?? {}, ctx.values.target)) {
1260
+ throw new Error(`Target ${ctx.values.target} is not defined in the target configuration.`);
1261
+ }
1262
+ const policy = document.targets?.[ctx.values.target]?.assetPacks;
1263
+ if (policy === undefined) {
1264
+ throw new Error(`Target ${ctx.values.target} has no assetPacks policy.`);
1265
+ }
1266
+ assertAssetPackTargetPolicy(policy);
1267
+ const report = await buildAssetPackTarget({ policy, baseDir: path.dirname(targetsPath), outDir: ctx.values.out });
1268
+ if (ctx.values.json) {
1269
+ console.info(JSON.stringify(report, null, 2));
1270
+ }
1271
+ else {
1272
+ console.info(`Packaged assets: ${report.packagedAssetBytes} bytes (${report.packagedBytes} including metadata)`);
1273
+ console.info(`Remote objects: ${report.remoteBytes} bytes; manifest sha256:${report.manifestSha256}`);
1274
+ console.info(`Asset pack deployment: ${report.outDir}`);
1275
+ }
1276
+ },
1277
+ }),
1198
1278
  'build-packs': defineI18n({
1199
1279
  name: 'build-packs',
1200
1280
  description: 'Build files or ZIP delivery artifacts for configured packs.',
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitGitSha": "84316fe7a7da51e1b448d8935240a5f60aaf4adc",
2
+ "kitGitSha": "ccd019a49651a6730a4f0214d9dfcf0094bda226",
3
3
  "kitDirty": false,
4
- "packageVersion": "0.39.0"
4
+ "packageVersion": "0.40.0"
5
5
  }