@mpgd/cli 0.29.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -375,6 +375,8 @@ function verifyIndexReferences(deploymentRoot, deploymentFiles) {
375
375
  const references = [...cssReferences];
376
376
  const metaRefreshContents = [];
377
377
  const embeddedDocuments = [];
378
+ const stylesheetHrefs = new Set();
379
+ const activeStylesheets = new Set();
378
380
  // Attributes only exist on start tags; scanning raw text would treat prose
379
381
  // like `Set href="..."` as a reference. Extract within element start tags,
380
382
  // with object data URLs and srcset candidates handled per tag.
@@ -385,18 +387,31 @@ function verifyIndexReferences(deploymentRoot, deploymentFiles) {
385
387
  const resourceNames = tagName === 'object'
386
388
  ? ['href', 'src', 'poster', 'data']
387
389
  : ['href', 'src', 'poster', 'xlink:href'];
388
- // Tokenize real attribute name/value pairs first so quoted values of
389
- // unrelated attributes (title='See href="..."') never leak references.
390
+ // Tokenize real attribute name/value pairs exactly once so every
391
+ // attribute-driven check (resource names, srcset, style, srcdoc, meta)
392
+ // shares one parse. Quoted values of unrelated attributes
393
+ // (title='See srcset="./missing.png"') can never leak references, and
394
+ // duplicate attributes keep their first occurrence like HTML parsing.
395
+ const seenAttributeNames = new Set();
396
+ const attributePairs = [];
397
+ for (const attribute of attributes.matchAll(/([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gu)) {
398
+ const name = (attribute[1] ?? '').toLowerCase();
399
+ if (seenAttributeNames.has(name)) {
400
+ continue;
401
+ }
402
+ seenAttributeNames.add(name);
403
+ attributePairs.push({ name, value: attribute[2] ?? attribute[3] ?? attribute[4] ?? '' });
404
+ }
405
+ // An iframe srcdoc overrides src even when its value is empty.
406
+ const attributesHasSrcdoc = tagName === 'iframe'
407
+ && attributePairs.some(({ name }) => name === 'srcdoc');
390
408
  let isMetaRefresh = false;
391
409
  let metaContent;
392
410
  let styleValue;
393
- // Detect srcdoc before iterating resource attributes so an iframe src
394
- // appearing before its srcdoc is already known to be overridden.
395
- const attributesHasSrcdoc = tagName === 'iframe'
396
- && /(?:^|\s)srcdoc\s*=\s*(?:"[^"]+"|'[^']+'|[^\s>]+)/iu.test(attributes);
397
- for (const attribute of attributes.matchAll(/([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gu)) {
398
- const name = (attribute[1] ?? '').toLowerCase();
399
- const value = attribute[2] ?? attribute[3] ?? attribute[4] ?? '';
411
+ let srcsetValue;
412
+ let linkRel;
413
+ let linkHref;
414
+ for (const { name, value } of attributePairs) {
400
415
  if (tagName === 'meta' && name === 'http-equiv' && value.trim().toLowerCase() === 'refresh') {
401
416
  isMetaRefresh = true;
402
417
  }
@@ -406,7 +421,16 @@ function verifyIndexReferences(deploymentRoot, deploymentFiles) {
406
421
  if (name === 'style') {
407
422
  styleValue = value;
408
423
  }
409
- if (!resourceNames.includes(name) && name !== 'srcset' && name !== 'srcdoc') {
424
+ if (name === 'srcset') {
425
+ srcsetValue = value;
426
+ }
427
+ if (tagName === 'link' && name === 'rel') {
428
+ linkRel = value.trim().toLowerCase();
429
+ }
430
+ if (tagName === 'link' && name === 'href') {
431
+ linkHref = value;
432
+ }
433
+ if (!resourceNames.includes(name) && name !== 'srcdoc') {
410
434
  continue;
411
435
  }
412
436
  if (name === 'srcdoc' && value.length > 0) {
@@ -415,49 +439,30 @@ function verifyIndexReferences(deploymentRoot, deploymentFiles) {
415
439
  embeddedDocuments.push(decodeHtmlReferences(value));
416
440
  continue;
417
441
  }
418
- if (name !== 'srcset' && value.length > 0) {
442
+ if (value.length > 0) {
419
443
  if (tagName === 'iframe' && name === 'src' && attributesHasSrcdoc) {
420
444
  continue;
421
445
  }
422
446
  references.push(value);
423
447
  }
424
448
  }
449
+ // Remember stylesheet links by their raw href so only files the browser
450
+ // actually applies as CSS join the stylesheet scan; a plain anchor to a
451
+ // .css file is just a downloadable reference. rel/href order-agnostic.
452
+ if (tagName === 'link' && linkHref !== undefined && isStylesheetRel(linkRel)) {
453
+ stylesheetHrefs.add(linkHref);
454
+ }
425
455
  if (isMetaRefresh && metaContent !== undefined) {
426
456
  metaRefreshContents.push(metaContent);
427
457
  }
428
458
  if (styleValue !== undefined) {
429
459
  collectInlineCssReferences(decodeHtmlReferences(styleValue), references);
430
460
  }
431
- if (!/(?:^|\s)srcset\s*=/iu.test(attributes)) {
461
+ if (srcsetValue === undefined) {
432
462
  continue;
433
463
  }
434
- const srcset = attributes.match(/(?:^|\s)srcset\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/iu);
435
- const rawCandidates = srcset?.[1] ?? srcset?.[2] ?? srcset?.[3] ?? '';
436
- const candidates = decodeHtmlReferences(rawCandidates);
437
- // WHATWG srcset tokenizing: whitespace-split tokens alternate between a
438
- // candidate URL and its descriptors; a numeric single-token value like
439
- // srcset="404" stays a URL, while densities after a URL are skipped.
440
- let holdingUrl = false;
441
- for (const token of candidates.split(/\s+/u)) {
442
- for (const piece of token.split(/(?<=\d[xw]),/u)) {
443
- const trimmed = piece.replace(/^[,]+|[,]+$/gu, '');
444
- if (trimmed.length === 0) {
445
- continue;
446
- }
447
- const isDescriptor = /^[\d.]+[wx]?$/u.test(trimmed);
448
- if (holdingUrl && isDescriptor) {
449
- continue;
450
- }
451
- references.push(trimmed);
452
- // A trailing comma on a URL token ends the candidate, so the next
453
- // token starts fresh rather than being read as a descriptor.
454
- const endedCandidate = /,$/u.test(piece);
455
- holdingUrl = !isDescriptor && !endedCandidate;
456
- }
457
- if (/^[\d.]+[wx],?$/u.test(token)) {
458
- holdingUrl = false;
459
- }
460
- }
464
+ // Shared WHATWG srcset tokenizer (also used by the legal-page pass).
465
+ pushSrcsetCandidates(decodeHtmlReferences(srcsetValue), references);
461
466
  }
462
467
  // Embedded srcdoc documents resolve their resources against this
463
468
  // deployment; scan them with the full tokenizer (resource attributes,
@@ -474,48 +479,6 @@ function verifyIndexReferences(deploymentRoot, deploymentFiles) {
474
479
  references.push(target[1] ?? target[2] ?? target[3] ?? '');
475
480
  }
476
481
  }
477
- // Referenced local stylesheets carry their own url()/@import targets;
478
- // resolve them relative to each stylesheet's directory.
479
- const stylesheetReferences = [];
480
- for (const reference of [...referenced]) {
481
- if (!reference.endsWith('.css') || !deploymentPaths.has(reference)) {
482
- continue;
483
- }
484
- const cssContent = readFileSync(join(deploymentRoot, reference), 'utf8');
485
- const cssDir = reference.includes('/')
486
- ? `./${reference.slice(0, reference.lastIndexOf('/'))}`
487
- : '.';
488
- for (const cssUrl of cssContent.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/giu)) {
489
- const raw = (cssUrl[2] ?? '').trim();
490
- if (raw.length === 0 || raw.startsWith('data:') || raw.startsWith('#')) {
491
- continue;
492
- }
493
- const scheme = /^([a-z][a-z0-9+.-]*):/iu.exec(raw);
494
- if (scheme !== null && scheme[1] !== undefined && scheme[1].toLowerCase() !== 'https' && scheme[1].toLowerCase() !== 'http') {
495
- continue;
496
- }
497
- const schemeText = scheme?.[1] ?? '';
498
- let cssPath = scheme !== null ? raw.slice(schemeText.length + 1) : raw;
499
- if (cssPath.startsWith('//')) {
500
- continue;
501
- }
502
- cssPath = cssPath.split('?')[0]?.split('#')[0] ?? cssPath;
503
- const cssTarget = cssPath.startsWith('/') ? cssPath.slice(1) : `${cssDir}/${cssPath}`;
504
- const resolved = posix.normalize(cssTarget);
505
- if (!resolved.startsWith('../')) {
506
- stylesheetReferences.push(resolved);
507
- }
508
- }
509
- for (const importMatch of cssContent.matchAll(/@import\s+(['"])([^'"]+)\1/giu)) {
510
- const raw = (importMatch[2] ?? '').trim();
511
- if (raw.length > 0 && !raw.startsWith('/') && !raw.startsWith('data:')) {
512
- stylesheetReferences.push(posix.normalize(`${cssDir}/${raw.split('?')[0] ?? raw}`));
513
- }
514
- }
515
- }
516
- for (const stylesheetReference of stylesheetReferences) {
517
- referenced.add(stylesheetReference);
518
- }
519
482
  for (const rawReference of references) {
520
483
  const reference = decodeHtmlReferences(rawReference).trim();
521
484
  const schemeSeparated = /^([a-z][a-z0-9+.-]*):/iu.exec(reference);
@@ -542,9 +505,6 @@ function verifyIndexReferences(deploymentRoot, deploymentFiles) {
542
505
  continue;
543
506
  }
544
507
  }
545
- if (process.env.MPGD_DEBUG_SRCDOC === '1') {
546
- console.error('DEBUG-REF', JSON.stringify(reference.slice(0, 60)), '->', JSON.stringify(effectiveReference.slice(0, 60)));
547
- }
548
508
  const withoutQuery = effectiveReference.split(/[?#]/u)[0] ?? effectiveReference;
549
509
  if (withoutQuery.length === 0) {
550
510
  continue;
@@ -576,8 +536,84 @@ function verifyIndexReferences(deploymentRoot, deploymentFiles) {
576
536
  if (normalized === '.' || normalized.endsWith('/')) {
577
537
  normalized = `${normalized === '.' ? '' : normalized}index.html`;
578
538
  }
539
+ if (stylesheetHrefs.has(rawReference)) {
540
+ activeStylesheets.add(normalized);
541
+ }
579
542
  referenced.add(normalized);
580
543
  }
544
+ // Stylesheet links (and the @import chain they start) carry their own
545
+ // url()/@import targets, resolved relative to each stylesheet's directory.
546
+ // Comments are stripped first, external URLs are skipped, root-relative
547
+ // targets resolve from the deployment root, percent-encoded segments
548
+ // decode, escaping targets are rejected, and chained imports follow with a
549
+ // visited set so circular imports cannot loop forever.
550
+ const visitedStylesheets = new Set();
551
+ const stylesheetQueue = [...activeStylesheets];
552
+ while (stylesheetQueue.length > 0) {
553
+ const stylesheet = stylesheetQueue.shift();
554
+ if (stylesheet === undefined || visitedStylesheets.has(stylesheet)) {
555
+ continue;
556
+ }
557
+ visitedStylesheets.add(stylesheet);
558
+ const { cssUrls, cssImports } = extractStylesheetTargets(readFileSync(join(deploymentRoot, stylesheet), 'utf8'));
559
+ const cssDir = stylesheet.includes('/')
560
+ ? `./${stylesheet.slice(0, stylesheet.lastIndexOf('/'))}`
561
+ : '.';
562
+ const resolvedUrls = [];
563
+ const resolvedImports = [];
564
+ const collectCssTarget = (rawTarget, sink) => {
565
+ const raw = decodeCssEscapes(rawTarget.trim());
566
+ if (raw.length === 0 || raw.startsWith('data:') || raw.startsWith('#')) {
567
+ return;
568
+ }
569
+ const scheme = /^([a-z][a-z0-9+.-]*):/iu.exec(raw);
570
+ if (scheme !== null
571
+ && scheme[1] !== undefined
572
+ && scheme[1].toLowerCase() !== 'https'
573
+ && scheme[1].toLowerCase() !== 'http') {
574
+ return;
575
+ }
576
+ const schemeText = scheme?.[1] ?? '';
577
+ let cssPath = scheme !== null ? raw.slice(schemeText.length + 1) : raw;
578
+ // Cross-scheme authority URLs (https://host/x, http:/host/x) are
579
+ // external; only same-scheme relative forms like https:./file.png
580
+ // resolve against the deployment.
581
+ if (scheme !== null && cssPath.startsWith('/')) {
582
+ return;
583
+ }
584
+ // Scheme-relative URLs without a scheme token (//host/x) are external.
585
+ if (scheme === null && cssPath.startsWith('//')) {
586
+ return;
587
+ }
588
+ cssPath = cssPath.split('?')[0]?.split('#')[0] ?? cssPath;
589
+ const cssTarget = cssPath.startsWith('/') ? cssPath.slice(1) : `${cssDir}/${cssPath}`;
590
+ const resolved = posix.normalize(cssTarget);
591
+ if (resolved.startsWith('../') || resolved === '..' || posix.isAbsolute(resolved)) {
592
+ throw new Error(`The stylesheet ${stylesheet} references a path outside the artifact root: ${raw}`);
593
+ }
594
+ sink.push(decodeReferenceSegments(resolved, `stylesheet ${stylesheet}`));
595
+ };
596
+ for (const cssUrl of cssUrls) {
597
+ collectCssTarget(cssUrl, resolvedUrls);
598
+ }
599
+ for (const cssImport of cssImports) {
600
+ collectCssTarget(cssImport, resolvedImports);
601
+ }
602
+ for (const resolvedUrl of resolvedUrls) {
603
+ referenced.add(resolvedUrl);
604
+ }
605
+ for (const resolvedImport of resolvedImports) {
606
+ referenced.add(resolvedImport);
607
+ // Imports are stylesheets by syntax whatever their URL looks like, and
608
+ // deduplication against earlier references must not suppress
609
+ // traversal: the browser still applies the imported file as CSS.
610
+ if (deploymentPaths.has(resolvedImport)
611
+ && !visitedStylesheets.has(resolvedImport)
612
+ && !stylesheetQueue.includes(resolvedImport)) {
613
+ stylesheetQueue.push(resolvedImport);
614
+ }
615
+ }
616
+ }
581
617
  for (const reference of [...referenced].sort()) {
582
618
  if (pagesControlArtifactNames.has(reference)) {
583
619
  throw new Error(`The deployment index.html references the Pages control file ${reference}; `
@@ -806,6 +842,147 @@ function collectEmbeddedDocumentReferences(document, references) {
806
842
  }
807
843
  }
808
844
  /** Tokenize srcset candidates into the reference list. */
845
+ /** Whether a link rel token list applies the target as a stylesheet. */
846
+ function isStylesheetRel(rel) {
847
+ return rel !== undefined && rel.split(/\s+/u).includes('stylesheet');
848
+ }
849
+ /**
850
+ * Extract live url() targets and @import targets from a stylesheet with a
851
+ * small string-aware tokenizer:
852
+ *
853
+ * - comments are skipped;
854
+ * - url()/@import text inside quoted strings is inert content, so a
855
+ * `content: "url(./missing.png)"` rule never becomes a reference;
856
+ * - @import is honored only before the first top-level style block, because
857
+ * CSS parsing makes later imports inert;
858
+ * - @import supports both quoted-string and url() forms.
859
+ */
860
+ function extractStylesheetTargets(css) {
861
+ const cssUrls = [];
862
+ const cssImports = [];
863
+ let index = 0;
864
+ let sawTopLevelBlock = false;
865
+ const skipString = () => {
866
+ const quote = css[index] ?? '';
867
+ index += 1;
868
+ while (index < css.length) {
869
+ const inner = css[index] ?? '';
870
+ index += 1;
871
+ if (inner === '\\') {
872
+ index += 1;
873
+ continue;
874
+ }
875
+ if (inner === quote) {
876
+ return;
877
+ }
878
+ }
879
+ };
880
+ while (index < css.length) {
881
+ const char = css[index] ?? '';
882
+ if (char === '"' || char === "'") {
883
+ skipString();
884
+ continue;
885
+ }
886
+ if (char === '/' && (css[index + 1] ?? '') === '*') {
887
+ const end = css.indexOf('*/', index + 2);
888
+ index = end === -1 ? css.length : end + 2;
889
+ continue;
890
+ }
891
+ if (char === '{') {
892
+ sawTopLevelBlock = true;
893
+ index += 1;
894
+ continue;
895
+ }
896
+ const rest = css.slice(index);
897
+ const urlMatch = /^url\(/iu.exec(rest);
898
+ if (urlMatch !== null) {
899
+ index += urlMatch[0].length;
900
+ let quote = '';
901
+ let argument = '';
902
+ while (index < css.length) {
903
+ const inner = css[index] ?? '';
904
+ if (quote === '' && (inner === '"' || inner === "'")) {
905
+ quote = inner;
906
+ index += 1;
907
+ continue;
908
+ }
909
+ if (quote !== '' && inner === quote) {
910
+ quote = '';
911
+ index += 1;
912
+ continue;
913
+ }
914
+ if (quote !== '' && inner === '\\') {
915
+ argument += inner + (css[index + 1] ?? '');
916
+ index += 2;
917
+ continue;
918
+ }
919
+ if (quote === '' && inner === ')') {
920
+ index += 1;
921
+ break;
922
+ }
923
+ argument += inner;
924
+ index += 1;
925
+ }
926
+ cssUrls.push(argument.trim());
927
+ continue;
928
+ }
929
+ const importMatch = !sawTopLevelBlock ? /^@import\b/iu.exec(rest) : null;
930
+ if (importMatch !== null) {
931
+ index += importMatch[0].length;
932
+ let captured = '';
933
+ while (index < css.length) {
934
+ const inner = css[index] ?? '';
935
+ if (inner === ';') {
936
+ index += 1;
937
+ break;
938
+ }
939
+ captured += inner;
940
+ index += 1;
941
+ }
942
+ const trimmed = captured.trim();
943
+ const quotedForm = /^(['"])([\s\S]*)\1$/u.exec(trimmed);
944
+ if (quotedForm !== null) {
945
+ cssImports.push((quotedForm[2] ?? '').trim());
946
+ }
947
+ else {
948
+ const urlForm = /^url\(\s*(['"]?)([^'")]*)\1\s*\)$/iu.exec(trimmed);
949
+ if (urlForm !== null) {
950
+ cssImports.push((urlForm[2] ?? '').trim());
951
+ }
952
+ }
953
+ continue;
954
+ }
955
+ index += 1;
956
+ }
957
+ return { cssUrls, cssImports };
958
+ }
959
+ /** Decode CSS string escapes (backslash-character and hex forms) like browsers do. */
960
+ function decodeCssEscapes(value) {
961
+ if (!value.includes('\\')) {
962
+ return value;
963
+ }
964
+ return value.replace(/\\(?:([0-9a-fA-F]{1,6})\s?|(.))/gu, (_match, hex, plain) => {
965
+ if (hex === undefined) {
966
+ return plain ?? '';
967
+ }
968
+ const codePoint = Number.parseInt(hex, 16);
969
+ return codePoint > 1114111 || Number.isNaN(codePoint)
970
+ ? '\uFFFD'
971
+ : String.fromCodePoint(codePoint);
972
+ });
973
+ }
974
+ /** Percent-decode each path segment, mirroring the HTML reference handling. */
975
+ function decodeReferenceSegments(path, label) {
976
+ try {
977
+ return path
978
+ .split('/')
979
+ .map((segment) => decodeURIComponent(segment))
980
+ .join('/');
981
+ }
982
+ catch {
983
+ throw new Error(`${label} references a malformed percent-encoded URL: ${path}`);
984
+ }
985
+ }
809
986
  function pushSrcsetCandidates(candidates, references) {
810
987
  let holdingUrl = false;
811
988
  for (const token of candidates.split(/\s+/u)) {
package/dist/index.js CHANGED
@@ -143,6 +143,8 @@ const entryCommand = defineI18n({
143
143
  ko: 'mpgd-kit 스타터와 타깃 워크플로우를 관리합니다.',
144
144
  }),
145
145
  run: () => {
146
+ console.info('Use a sub-command: game, target, kit.');
147
+ console.info('Run "pnpm mpgd --help" for available commands.');
146
148
  },
147
149
  });
148
150
  const gameCommand = defineI18n({
@@ -478,6 +480,9 @@ const gameCommand = defineI18n({
478
480
  outputDir,
479
481
  maximumBytes,
480
482
  });
483
+ console.info(`Offline playtest (test only): ${result.entryFile}`);
484
+ console.info(`Evidence: ${result.evidenceFile}`);
485
+ console.info('Not a release target, deployment artifact, PWA, or store-submission package.');
481
486
  },
482
487
  }),
483
488
  icons: defineI18n({
@@ -534,10 +539,12 @@ const gameCommand = defineI18n({
534
539
  }),
535
540
  ])),
536
541
  run: () => {
542
+ console.info('Use "mpgd game icons generate <game>", verify, or inspect.');
537
543
  },
538
544
  }),
539
545
  },
540
546
  run: () => {
547
+ console.info('Use "mpgd game create <directory>", "mpgd game accept <game>", "mpgd game offline-playtest <game>", or "mpgd game icons generate <game>".');
541
548
  },
542
549
  });
543
550
  const defaultLegalDir = 'legal';
@@ -699,9 +706,12 @@ function acceptGame(input) {
699
706
  MPGD_GAMEPLAY_E2E_REPORT_FILE: gameplayE2EReportFile,
700
707
  },
701
708
  });
709
+ console.info(`[mpgd:accept] JSON report: ${result.jsonFile}`);
710
+ console.info(`[mpgd:accept] Markdown report: ${result.markdownFile}`);
702
711
  if (result.report.status === 'failed') {
703
712
  throw new Error(`Game acceptance failed. Report: ${result.markdownFile}`);
704
713
  }
714
+ console.info('[mpgd:accept] passed');
705
715
  }
706
716
  function assertGameAcceptanceRoot(gameRoot) {
707
717
  const resolved = path.resolve(gameRoot);
@@ -785,6 +795,7 @@ const legalCommand = defineI18n({
785
795
  ...resolveLegalCommandOptions(ctx.values),
786
796
  check: false,
787
797
  });
798
+ console.info(`Built legal site: ${result.outDir}`);
788
799
  },
789
800
  }),
790
801
  check: defineI18n({
@@ -800,10 +811,12 @@ const legalCommand = defineI18n({
800
811
  ...resolveLegalCommandOptions(ctx.values),
801
812
  check: true,
802
813
  });
814
+ console.info(`Legal site checked: ${result.outDir}`);
803
815
  },
804
816
  }),
805
817
  },
806
818
  run: () => {
819
+ console.info('Use "mpgd legal build" or "mpgd legal check".');
807
820
  },
808
821
  });
809
822
  const targetCommand = defineI18n({
@@ -873,9 +886,10 @@ const targetCommand = defineI18n({
873
886
  const resolvedKitPath = configuredKitPath === undefined
874
887
  ? detectedKitRoot
875
888
  : assertKitRoot(path.resolve(configuredKitPath));
889
+ const canonicalGameRoot = existsSync(gameRoot) ? realpathSync(gameRoot) : gameRoot;
876
890
  const defaultKitPath = resolvedKitPath === undefined
877
891
  ? defaultMpgdKitPath
878
- : toTemplatePath(path.relative(gameRoot, resolvedKitPath) || '.');
892
+ : toTemplatePath(path.relative(canonicalGameRoot, resolvedKitPath) || '.');
879
893
  const dryRun = ctx.values['dry-run'] === true;
880
894
  const adapterDependencyVersion = resolveMicrosoftStoreAdapterDependencyVersion(requireMpgdDependencyVersion(resolveMpgdDependencyVersionReplacements({
881
895
  workspace: false,
@@ -888,7 +902,9 @@ const targetCommand = defineI18n({
888
902
  adapterDependencyVersion,
889
903
  dryRun,
890
904
  });
905
+ console.info(`${dryRun ? 'Would update' : 'Updated'} Microsoft Store starter workflow: ${result.changedFiles.length} file(s).`);
891
906
  for (const file of result.changedFiles) {
907
+ console.info(`- ${file}`);
892
908
  }
893
909
  },
894
910
  }),
@@ -1095,6 +1111,136 @@ const targetCommand = defineI18n({
1095
1111
  report: verification,
1096
1112
  markdown: renderHostedPwaVerificationMarkdown(verification),
1097
1113
  });
1114
+ console.info(`Hosted PWA deployment verified: ${verification.verifiedGameFileCount} game `
1115
+ + `file(s) from revision ${verification.revision} under the `
1116
+ + `${verification.host}/${verification.profile} profile `
1117
+ + `(routes: ${verification.workerRoutes.include.join(', ')}).`);
1118
+ },
1119
+ }),
1120
+ 'preview-versions': defineI18n({
1121
+ name: 'preview-versions',
1122
+ description: 'Preview candidate platform version numbers for a release ledger without reserving them.',
1123
+ resource: commandResource({
1124
+ en: 'Preview candidate platform version numbers for a release ledger without reserving them.',
1125
+ ko: '릴리스 원장에 대한 플랫폼 버전 번호 후보를 예약 없이 미리 확인합니다.',
1126
+ }, {
1127
+ 'ledger-file': {
1128
+ en: 'Path to the platform version ledger JSON file (read-only).',
1129
+ ko: '플랫폼 버전 원장 JSON 파일 경로(읽기 전용).',
1130
+ },
1131
+ 'plan-file': {
1132
+ en: 'Optional prepared release plan JSON to revalidate and extend.',
1133
+ ko: '재검증하고 확장할 선택적 준비된 릴리스 계획 JSON.',
1134
+ },
1135
+ 'game-id': {
1136
+ en: 'Game identifier used in the release buildId.',
1137
+ ko: '릴리스 buildId에 사용할 게임 식별자.',
1138
+ },
1139
+ 'game-version': {
1140
+ en: 'Final game SemVer for the release.',
1141
+ ko: '릴리스의 최종 게임 SemVer.',
1142
+ },
1143
+ 'source-git-sha': {
1144
+ en: 'Full 40-character Git SHA of the game source.',
1145
+ ko: '게임 소스의 전체 40자 Git SHA.',
1146
+ },
1147
+ 'kit-git-sha': {
1148
+ en: 'Full 40-character Git SHA of the mpgd-kit checkout.',
1149
+ ko: 'mpgd-kit 체크아웃의 전체 40자 Git SHA.',
1150
+ },
1151
+ 'target-config-digest': {
1152
+ en: '64-character SHA-256 digest of the game-owned target configuration.',
1153
+ ko: '게임 소유 타깃 설정의 64자 SHA-256 다이제스트.',
1154
+ },
1155
+ targets: {
1156
+ en: 'Comma-separated targets, optionally "name:hosted-content-only" for microsoft-store.',
1157
+ ko: '쉼표로 구분된 타깃. microsoft-store는 "name:hosted-content-only" 지정 가능.',
1158
+ },
1159
+ json: {
1160
+ en: 'Emit the candidate allocation as JSON.',
1161
+ ko: '후보 할당을 JSON으로 출력합니다.',
1162
+ },
1163
+ 'kit-path': {
1164
+ en: 'Path to the mpgd-kit checkout.',
1165
+ ko: 'mpgd-kit 체크아웃 경로.',
1166
+ },
1167
+ }),
1168
+ args: {
1169
+ 'ledger-file': {
1170
+ type: 'string',
1171
+ required: true,
1172
+ description: 'Path to the platform version ledger JSON file (read-only).',
1173
+ },
1174
+ 'plan-file': {
1175
+ type: 'string',
1176
+ required: false,
1177
+ description: 'Optional prepared release plan JSON to revalidate and extend.',
1178
+ },
1179
+ 'game-id': {
1180
+ type: 'string',
1181
+ required: true,
1182
+ description: 'Game identifier used in the release buildId.',
1183
+ },
1184
+ 'game-version': {
1185
+ type: 'string',
1186
+ required: true,
1187
+ description: 'Final game SemVer for the release.',
1188
+ },
1189
+ 'source-git-sha': {
1190
+ type: 'string',
1191
+ required: true,
1192
+ description: 'Full 40-character Git SHA of the game source.',
1193
+ },
1194
+ 'kit-git-sha': {
1195
+ type: 'string',
1196
+ required: true,
1197
+ description: 'Full 40-character Git SHA of the mpgd-kit checkout.',
1198
+ },
1199
+ 'target-config-digest': {
1200
+ type: 'string',
1201
+ required: true,
1202
+ description: '64-character SHA-256 digest of the game-owned target configuration.',
1203
+ },
1204
+ targets: {
1205
+ type: 'string',
1206
+ required: true,
1207
+ description: 'Comma-separated targets, optionally "name:hosted-content-only" for microsoft-store.',
1208
+ },
1209
+ json: {
1210
+ type: 'boolean',
1211
+ required: false,
1212
+ description: 'Emit the candidate allocation as JSON.',
1213
+ },
1214
+ 'kit-path': {
1215
+ type: 'string',
1216
+ required: false,
1217
+ description: 'Path to the mpgd-kit checkout.',
1218
+ },
1219
+ },
1220
+ run: (ctx) => {
1221
+ const kitPath = resolveKitPathForTarget(ctx.values);
1222
+ const planFile = readOptionalString(ctx.values['plan-file']);
1223
+ const env = {
1224
+ ...process.env,
1225
+ MPGD_KIT_PATH: kitPath,
1226
+ MPGD_PREVIEW_LEDGER_FILE: path.resolve(readRequiredCliOption(ctx.values['ledger-file'], '--ledger-file')),
1227
+ MPGD_PREVIEW_GAME_ID: readRequiredCliOption(ctx.values['game-id'], '--game-id'),
1228
+ MPGD_PREVIEW_GAME_VERSION: readRequiredCliOption(ctx.values['game-version'], '--game-version'),
1229
+ MPGD_PREVIEW_SOURCE_GIT_SHA: readRequiredCliOption(ctx.values['source-git-sha'], '--source-git-sha'),
1230
+ MPGD_PREVIEW_KIT_GIT_SHA: readRequiredCliOption(ctx.values['kit-git-sha'], '--kit-git-sha'),
1231
+ MPGD_PREVIEW_TARGET_CONFIG_DIGEST: readRequiredCliOption(ctx.values['target-config-digest'], '--target-config-digest'),
1232
+ MPGD_PREVIEW_TARGETS: readRequiredCliOption(ctx.values.targets, '--targets'),
1233
+ // Always define the optional toggles so ambient values from the
1234
+ // caller's environment cannot override absent flags.
1235
+ MPGD_PREVIEW_PLAN_FILE: planFile === undefined ? '' : path.resolve(planFile),
1236
+ MPGD_PREVIEW_JSON: ctx.values.json === true ? '1' : '',
1237
+ };
1238
+ if (ctx.values.json !== true) {
1239
+ console.info('[mpgd] target preview-versions (read-only candidates, not reservations)');
1240
+ }
1241
+ // --silent keeps pnpm's script echo out of stdout so the JSON document
1242
+ // is machine-readable apart from the framework banner.
1243
+ runPnpm(['--silent', 'preview:platform-versions'], env);
1098
1244
  },
1099
1245
  }),
1100
1246
  'generate-package': defineI18n({
@@ -1216,6 +1362,7 @@ const targetCommand = defineI18n({
1216
1362
  jsonFile: path.join(outputDir, 'package-generation.json'),
1217
1363
  markdownFile: path.join(outputDir, 'package-generation.md'),
1218
1364
  });
1365
+ console.info(`Microsoft Store package ZIP downloaded to ${evidence.archive.file}; package inspection remains required.`);
1219
1366
  },
1220
1367
  }),
1221
1368
  preflight: defineI18n({
@@ -1294,6 +1441,7 @@ const targetCommand = defineI18n({
1294
1441
  jsonFile: path.join(outputDir, 'submission-preflight.json'),
1295
1442
  markdownFile: path.join(outputDir, 'submission-preflight.md'),
1296
1443
  });
1444
+ console.info(`Microsoft Store submission preflight passed: ${Object.keys(evidence.listing.locales).length} listing locale(s), ${evidence.warnings.length} warning(s).`);
1297
1445
  },
1298
1446
  }),
1299
1447
  'accept-package': defineI18n({
@@ -1408,6 +1556,7 @@ const targetCommand = defineI18n({
1408
1556
  ...(appCertExecutable === undefined ? {} : { appCertExecutable }),
1409
1557
  ...(makeAppxExecutable === undefined ? {} : { makeAppxExecutable }),
1410
1558
  }));
1559
+ console.info(`Microsoft Store package acceptance passed: ${evidence.packages.length} package(s).`);
1411
1560
  },
1412
1561
  }),
1413
1562
  'build-all': defineI18n({
@@ -1508,6 +1657,7 @@ const targetCommand = defineI18n({
1508
1657
  }),
1509
1658
  },
1510
1659
  run: () => {
1660
+ console.info('Use "mpgd target init <target>", "mpgd target build <target>", "mpgd target smoke <target>", "mpgd target generate-package <target>", "mpgd target preflight <target>", or "mpgd target accept-package <target> --packages <paths>".');
1511
1661
  },
1512
1662
  });
1513
1663
  const kitCommand = defineI18n({
@@ -1526,10 +1676,17 @@ const kitCommand = defineI18n({
1526
1676
  ko: 'CLI, kit, template, target wrapper 상태를 출력합니다.',
1527
1677
  }),
1528
1678
  run: () => {
1679
+ console.info(`cli package: ${packageRoot}`);
1680
+ console.info(`mpgd-kit: ${detectedKitRoot ?? 'not detected'}`);
1681
+ console.info(`cli template: ${existsSync(gameTemplateDir) ? 'ok' : 'missing'}`);
1682
+ console.info(`mobile wrapper: ${kitFileStatus('apps/mobile-capacitor/package.json')}`);
1683
+ console.info(`ait wrapper: ${kitFileStatus('apps/target-ait/package.json')}`);
1684
+ console.info(`devvit wrapper: ${kitFileStatus('apps/target-devvit/package.json')}`);
1529
1685
  },
1530
1686
  }),
1531
1687
  },
1532
1688
  run: () => {
1689
+ console.info('Use "mpgd kit doctor".');
1533
1690
  },
1534
1691
  });
1535
1692
  function commandResource(description, args = {}) {
@@ -1758,10 +1915,12 @@ function createGameApp(input) {
1758
1915
  : [{ ...file, content: renderTemplate(prepared, context) }];
1759
1916
  });
1760
1917
  if (input.dryRun) {
1918
+ console.info(`Would create ${path.relative(process.cwd(), appDir) || appDir}:`);
1761
1919
  const plannedFiles = input.microsoftStore
1762
1920
  ? [...files.map((file) => file.relativePath), ...listMicrosoftStoreInitializerTemplateFiles()]
1763
1921
  : files.map((file) => file.relativePath);
1764
1922
  for (const relativePath of [...new Set(plannedFiles)].sort()) {
1923
+ console.info(`- ${path.join(path.relative(process.cwd(), appDir), relativePath)}`);
1765
1924
  }
1766
1925
  return;
1767
1926
  }
@@ -1796,6 +1955,20 @@ function createGameApp(input) {
1796
1955
  }
1797
1956
  throw error;
1798
1957
  }
1958
+ console.info(`Created ${appDir}`);
1959
+ console.info('Next steps:');
1960
+ console.info(` cd ${appDir}`);
1961
+ console.info(' pnpm install -w');
1962
+ console.info(' pnpm dev');
1963
+ console.info('Target builds require an mpgd-kit checkout:');
1964
+ console.info([
1965
+ ' mpgd target build-all',
1966
+ `--targets-file ${path.join(appDir, 'mpgd.targets.json')}`,
1967
+ `--targets ${context.recommendedMatrixTargets}`,
1968
+ '--profile staging',
1969
+ '--ait-variant wrapper',
1970
+ '--kit-path <path-to-mpgd-kit>',
1971
+ ].join(' '));
1799
1972
  }
1800
1973
  function assertValidGameName(name) {
1801
1974
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
@@ -2175,6 +2348,7 @@ function runTargetCommand(input) {
2175
2348
  const args = input.action === 'build'
2176
2349
  ? ['build:target', target, profile]
2177
2350
  : ['smoke:target', target];
2351
+ console.info(`[mpgd] ${input.action} ${target}`);
2178
2352
  runPnpm(args, env);
2179
2353
  }
2180
2354
  function runTargetMatrix(input) {
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { closeSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
+ import { closeSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { escapeMarkdownInline, escapeMarkdownTable, formatError, relativeOrAbsolute, } from './evidence-io.js';
@@ -85,10 +85,10 @@ export function runMicrosoftStorePackageAcceptance(input, runtime = createMicros
85
85
  ...packageFiles.map((file) => ({ file, label: 'Microsoft Store package' })),
86
86
  ]);
87
87
  for (const reportFile of reportFiles) {
88
- rmSync(reportFile, { force: true });
88
+ removeEvidenceFile(reportFile);
89
89
  }
90
- rmSync(jsonFile, { force: true });
91
- rmSync(markdownFile, { force: true });
90
+ removeEvidenceFile(jsonFile);
91
+ removeEvidenceFile(markdownFile);
92
92
  const tempRoot = mkdtempSync(path.join(tmpdir(), 'mpgd-microsoft-store-package-'));
93
93
  try {
94
94
  const packages = packageFiles.map((packageFile, index) => {
@@ -136,10 +136,10 @@ export function runMicrosoftStorePackageAcceptance(input, runtime = createMicros
136
136
  writeFileSync(markdownFile, renderMicrosoftStorePackageAcceptanceMarkdown(evidence));
137
137
  }
138
138
  catch (error) {
139
- rmSync(jsonFile, { force: true });
140
- rmSync(markdownFile, { force: true });
139
+ removeEvidenceFile(jsonFile);
140
+ removeEvidenceFile(markdownFile);
141
141
  for (const reportFile of reportFiles) {
142
- rmSync(reportFile, { force: true });
142
+ removeEvidenceFile(reportFile);
143
143
  }
144
144
  throw error;
145
145
  }
@@ -170,7 +170,7 @@ function runOptionalWindowsAppCertification(input) {
170
170
  if (reportFile === undefined) {
171
171
  throw new Error('Windows App Certification Kit report path was not prepared.');
172
172
  }
173
- rmSync(reportFile, { force: true });
173
+ removeEvidenceFile(reportFile);
174
174
  input.runtime.runCommand(appCertExecutable, ['reset']);
175
175
  input.runtime.runCommand(appCertExecutable, [
176
176
  'test',
@@ -714,6 +714,17 @@ function sameFile(left, right) {
714
714
  return false;
715
715
  }
716
716
  }
717
+ /** Remove only the file or link, never traverse a replacement directory link. */
718
+ function removeEvidenceFile(file) {
719
+ try {
720
+ unlinkSync(file);
721
+ }
722
+ catch (error) {
723
+ if (!isMissingFileError(error)) {
724
+ throw error;
725
+ }
726
+ }
727
+ }
717
728
  function isMissingFileError(error) {
718
729
  if (typeof error !== 'object' || error === null) {
719
730
  return false;
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { decodeHTMLAttribute } from 'entities';
5
- import { build } from 'esbuild';
5
+ import { build, transform } from 'esbuild';
6
6
  export const defaultOfflinePlaytestArtifactDir = 'artifacts/web-preview';
7
7
  export const defaultOfflinePlaytestOutputDir = 'artifacts/offline-playtest';
8
8
  export const defaultOfflinePlaytestMaximumBytes = 25 * 1024 * 1024;
@@ -530,7 +530,11 @@ async function bundleEntry(entryFile, context) {
530
530
  platform: 'browser',
531
531
  target: 'es2022',
532
532
  keepNames: true,
533
- minify: true,
533
+ minifyWhitespace: true,
534
+ minifySyntax: true,
535
+ // Random deferred markers must not influence esbuild's short identifier choices.
536
+ // Keep their collision resistance; rename identifiers after resolving the markers.
537
+ minifyIdentifiers: false,
534
538
  metafile: true,
535
539
  sourcemap: false,
536
540
  legalComments: 'none',
@@ -542,7 +546,18 @@ async function bundleEntry(entryFile, context) {
542
546
  throw new Error('The offline playtest bundler did not produce JavaScript.');
543
547
  }
544
548
  recordRetainedBundledAssets(result.metafile, context);
545
- const script = resolveDeferredJavaScriptAssets(scriptOutput.text, deferredAssets, context);
549
+ const resolvedScript = resolveDeferredJavaScriptAssets(scriptOutput.text, deferredAssets, context);
550
+ const { code: script } = await transform(resolvedScript, {
551
+ loader: 'js',
552
+ format: 'esm',
553
+ target: 'es2022',
554
+ keepNames: true,
555
+ minifyIdentifiers: true,
556
+ minifyWhitespace: true,
557
+ minifySyntax: false,
558
+ legalComments: 'none',
559
+ sourcemap: false,
560
+ });
546
561
  const stylesheetOutput = result.outputFiles.find((file) => file.path.endsWith('.css'));
547
562
  assertNoEntryImportMetaUrl(script);
548
563
  assertSupportedBundledRuntime(script);
@@ -1271,7 +1286,8 @@ function parseStaticElementConstructor(initializer, rawInitializer, allowAnyCrea
1271
1286
  if (constructor?.[2] === undefined) {
1272
1287
  const initializerCodePositions = createCodePositionMap(rawInitializer, true);
1273
1288
  const normalizedInitializer = maskNonCode(rawInitializer, initializerCodePositions).trim();
1274
- const createdElement = /^(?:(globalThis|self|window)\s*\.\s*)?document\s*\.\s*createElement\s*\(\s*\)\s*$/u.exec(normalizedInitializer);
1289
+ // The code mask retains the opening quote of a literal so token boundaries survive.
1290
+ const createdElement = /^(?:(globalThis|self|window)\s*\.\s*)?document\s*\.\s*createElement\s*\(\s*["'`]\s*\)\s*$/u.exec(normalizedInitializer);
1275
1291
  let openingParenthesis = -1;
1276
1292
  for (let index = 0; index < rawInitializer.length; index += 1) {
1277
1293
  if (rawInitializer[index] === '(' && initializerCodePositions[index] === 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.29.0",
3
+ "version": "0.30.1",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,19 +52,19 @@
52
52
  "@types/node": "^24.0.0",
53
53
  "ttsc": "0.18.4",
54
54
  "typescript": "7.0.2",
55
- "@mpgd/adapter-browser": "0.7.3",
56
- "@mpgd/adapter-ait": "0.12.1",
57
55
  "@mpgd/adapter-capacitor": "0.4.10",
58
- "@mpgd/adapter-devvit": "0.9.6",
59
- "@mpgd/adapter-verse8": "0.3.4",
56
+ "@mpgd/adapter-ait": "0.12.1",
57
+ "@mpgd/adapter-browser": "0.7.3",
58
+ "@mpgd/adapter-devvit": "0.9.8",
59
+ "@mpgd/adapter-verse8": "0.3.6",
60
60
  "@mpgd/analytics": "0.3.12",
61
- "@mpgd/bridge": "0.9.0",
62
61
  "@mpgd/catalog": "0.7.2",
63
- "@mpgd/i18n": "0.6.1",
64
- "@mpgd/game-services": "0.14.0",
65
- "@mpgd/phaser-assets": "0.4.1",
62
+ "@mpgd/game-services": "0.15.1",
63
+ "@mpgd/bridge": "0.9.0",
66
64
  "@mpgd/platform": "0.12.0",
67
- "@mpgd/target-config": "0.14.1"
65
+ "@mpgd/target-config": "0.15.0",
66
+ "@mpgd/phaser-assets": "0.4.1",
67
+ "@mpgd/i18n": "0.6.1"
68
68
  },
69
69
  "main": "./dist/index.js",
70
70
  "types": "./dist/index.d.ts",