@scrymore/scry-deployer 0.12.0-next.20261006020714 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -301,7 +301,7 @@ uploaded and hosted in every case below, so the preview link works; the red run
301
301
  | What happened | Log line | Exit code |
302
302
  |---|---|---|
303
303
  | Stories captured, metadata uploaded and queued | `⏳ Indexing has been queued, not finished.` | 0 |
304
- | Metadata upload rejected by the service | `❌ The metadata upload failed (<reason>), so NOTHING WILL BE INDEXED.` | 1 |
304
+ | Metadata upload rejected by the service (a failed PUT or complete also marks the build failed) | `❌ The metadata upload failed (<reason>), so NOTHING WILL BE INDEXED.` | 1 |
305
305
  | Metadata uploaded but not queued | `❌ Metadata was uploaded but not queued for processing, so NOTHING WILL BE INDEXED.` | 1 |
306
306
  | Analysis captured 0 stories (the empty archive is not uploaded, no build is queued) | `❌ Analysis captured 0 of N stories, so NOTHING WILL BE INDEXED.` plus the first capture error | 1 |
307
307
  | Analysis produced no archive (for example, no Playwright browser) | `❌ Analysis produced no metadata, so NOTHING WILL BE INDEXED.` | 1 |
@@ -320,6 +320,16 @@ which does not know the flag, it is not passed and the log says dropped stories
320
320
  Before 0.7.0 the metadata-upload failure, the "not queued" case, an empty archive, a non-zero
321
321
  scry-sbcov exit and a workflow that simply forgot `--with-analysis` all ended green (ISSUES.md #50).
322
322
 
323
+ ### Large metadata ZIPs (0.12.0)
324
+
325
+ The metadata ZIP is sent straight to storage on a presigned URL (presign, PUT, complete), not
326
+ through the upload service, so it can be larger than 100 MiB (limit 2 GiB). It used to be POSTed
327
+ through the service with a 100 MiB cap, and a library whose ZIP passed that (the dashboard's was
328
+ 101.5 MiB) indexed nothing and left its build "pending" (ISSUES.md #74). If the PUT or complete step
329
+ fails, the build is marked failed with a reason, the log says `marked failed`, and the run exits 1.
330
+ Against an upload service that has no presign route yet, the old route is used for a ZIP of up to
331
+ 100 MiB (the log says so); a larger ZIP stops with `upload service too old for a ZIP this size`.
332
+
323
333
  ### CI time (0.9.0)
324
334
 
325
335
  Every deploy measures how much CI time Scry took and records it with the build (ISSUES.md #54:
package/bin/cli.js CHANGED
@@ -520,12 +520,21 @@ function reportMetadataUploadFailed(metadataUpload, uploadResult, logger) {
520
520
  const zip = uploadResult?.zipUpload || {};
521
521
  const buildNumberSuffix = zip.buildNumber !== undefined ? ` (#${zip.buildNumber})` : '';
522
522
  const build = zip.buildId ? `Build ${zip.buildId}${buildNumberSuffix}` : 'The build';
523
+ let outcome;
524
+ if (metadataUpload.markedFailed) {
525
+ outcome = ` ${build} is marked failed on the upload service. Re-run this job to index it.`;
526
+ } else if (metadataUpload.markFailedUnsupported) {
527
+ outcome = ` ${build} is left pending: the upload service has no way to mark it failed,\n` +
528
+ ' so it will not show as failed. Re-run this job to index it.';
529
+ } else {
530
+ outcome = ` ${build} is left pending: it could not be marked failed (see the warning above),\n` +
531
+ ' so it will not show as failed. Re-run this job to index it.';
532
+ }
523
533
  logger.error(
524
534
  `\n❌ The metadata upload failed (${metadataUpload.error || 'no reason given'}), so NOTHING WILL BE INDEXED.\n` +
525
535
  (metadataUpload.requestId ? `Ref: ${metadataUpload.requestId}\n` : '') +
526
536
  HOSTED_NOT_SEARCHABLE + '\n' +
527
- ` ${build} is left pending: the upload service has no way to mark it failed,\n` +
528
- ' so it will not show as failed. Re-run this job to index it.'
537
+ outcome
529
538
  );
530
539
  }
531
540
 
package/lib/apiClient.js CHANGED
@@ -360,9 +360,12 @@ function validatePresignedUrl(presignedUrl) {
360
360
  * @param {string} presignedUrl
361
361
  * @param {Buffer|import('stream').Readable} data
362
362
  * @param {string} contentType
363
+ * @param {{timeout?: number}} [options] timeout in ms (axios: a stalled socket
364
+ * is aborted after this long without traffic); omitted keeps the old
365
+ * no-timeout behaviour for the Storybook archive.
363
366
  * @returns {Promise<{status:number}>}
364
367
  */
365
- async function putToPresignedUrl(presignedUrl, data, contentType) {
368
+ async function putToPresignedUrl(presignedUrl, data, contentType, options = {}) {
366
369
  const size = Buffer.isBuffer(data) ? `${data.length} bytes` : 'stream';
367
370
  logger.debug(`Starting PUT upload to presigned URL. Size: ${size}, Content-Type: ${contentType}`);
368
371
 
@@ -372,6 +375,7 @@ async function putToPresignedUrl(presignedUrl, data, contentType) {
372
375
  },
373
376
  maxContentLength: Infinity,
374
377
  maxBodyLength: Infinity,
378
+ ...(options.timeout ? { timeout: options.timeout } : {}),
375
379
  // Use a separate timeout for the actual upload if needed,
376
380
  // but here we rely on the global axios or the one passed in.
377
381
  });
@@ -498,9 +502,106 @@ async function uploadCoverageReportDirectly(apiClient, target, coverageReport) {
498
502
  }
499
503
  }
500
504
 
505
+ // The upload Worker refuses a metadata body above this on the old
506
+ // POST .../metadata route (and the deployer never sent more through it).
507
+ const METADATA_OLD_ROUTE_MAX_BYTES = 100 * 1024 * 1024;
508
+
509
+ function metadataUrl(target, suffix, query = '') {
510
+ const project = encodeURIComponent(target.project || 'main');
511
+ const version = encodeURIComponent(target.version || 'latest');
512
+ return `/upload/${project}/${version}/metadata${suffix}${query}`;
513
+ }
514
+
515
+ /** An upload service older than the presign route answers 404 (no such route). */
516
+ function isPresignRouteMissing(error) {
517
+ return error?.response?.status === 404;
518
+ }
519
+
520
+ /**
521
+ * Old route: POST the whole ZIP through the upload Worker. Kept for an upload
522
+ * service that has no presign route, up to METADATA_OLD_ROUTE_MAX_BYTES.
523
+ */
524
+ async function uploadMetadataZipViaWorker(apiClient, target, fileBuffer, query, customLogger) {
525
+ const response = await postThroughService(apiClient, metadataUrl(target, '', query), fileBuffer, {
526
+ label: 'Metadata ZIP',
527
+ bytes: fileBuffer.length,
528
+ headers: { 'Content-Type': 'application/zip' },
529
+ log: customLogger,
530
+ });
531
+ const data = response.data || {};
532
+ customLogger.success(
533
+ `Metadata ZIP uploaded (build #${data.buildNumber ?? 'n/a'}, queued: ${Boolean(data.queued)})`
534
+ );
535
+ return {
536
+ success: true,
537
+ status: response.status,
538
+ queued: Boolean(data.queued),
539
+ buildNumber: data.buildNumber,
540
+ zipKey: data.zipKey,
541
+ };
542
+ }
543
+
501
544
  /**
502
- * Upload metadata+screenshots ZIP.
503
- * This triggers async build-processing through the upload service queue.
545
+ * Ask the upload service to mark the build failed, with a reason, so a metadata
546
+ * upload that did not finish does not leave the build "pending" for ever.
547
+ * Never throws: when this cannot be done the caller is told, and the warning is
548
+ * printed here, so the build being left pending is never silent.
549
+ *
550
+ * @returns {Promise<boolean>} true when the service recorded the failure
551
+ */
552
+ async function markMetadataUploadFailed(apiClient, target, buildId, reason, customLogger) {
553
+ if (!buildId) {
554
+ customLogger.warn('The build could not be marked failed: no build id is known (the upload service did not return one).');
555
+ return false;
556
+ }
557
+ try {
558
+ await postThroughService(apiClient, metadataUrl(target, '/failed'), { buildId, reason }, {
559
+ label: 'Mark build failed',
560
+ bytes: 0,
561
+ headers: { 'Content-Type': 'application/json' },
562
+ log: customLogger,
563
+ });
564
+ customLogger.info(`Build ${buildId} marked failed on the upload service.`);
565
+ return true;
566
+ } catch (error) {
567
+ customLogger.warn(
568
+ `The build could not be marked failed (${sanitizeServerText(error.response?.data?.error || describeUploadError(error))}); ` +
569
+ `build ${buildId} stays pending.`
570
+ );
571
+ return false;
572
+ }
573
+ }
574
+
575
+ /**
576
+ * presign + PUT in one retried unit: the presigned URL is signed at request
577
+ * time, so every attempt asks for a fresh one. `stage` says which half failed.
578
+ */
579
+ async function presignAndPutMetadataZip(apiClient, target, fileBuffer, stage) {
580
+ return withUploadRetry(async () => {
581
+ stage.name = 'presign';
582
+ const response = await apiClient.post(metadataUrl(target, '/presign'), {}, { headers: { 'Content-Type': 'application/json' } });
583
+ const { url, key, buildId } = response.data || {};
584
+ if (!url || typeof url !== 'string') throw new ApiError(`The metadata presign response had no url (HTTP ${response.status}).`);
585
+ if (!key || typeof key !== 'string') throw new ApiError(`The metadata presign response had no key (HTTP ${response.status}).`);
586
+ if (!buildId || typeof buildId !== 'string') throw new ApiError(`The metadata presign response had no buildId (HTTP ${response.status}).`);
587
+ stage.buildId = buildId;
588
+ validatePresignedUrl(url);
589
+ stage.name = 'put';
590
+ // A PUT that stalls must end (and be retried, then marked failed), never hang the deploy.
591
+ await putToPresignedUrl(url, fileBuffer, 'application/zip', { timeout: uploadTimeoutMs(fileBuffer.length) });
592
+ return { buildId, buildNumber: response.data?.buildNumber, zipKey: key };
593
+ }, 'Metadata ZIP upload to storage');
594
+ }
595
+
596
+ /**
597
+ * Upload the metadata+screenshots ZIP and queue it for processing.
598
+ *
599
+ * The ZIP goes straight to storage on a presigned URL (presign, PUT, complete),
600
+ * so it never passes through the upload Worker and has no 100 MiB cap. Against
601
+ * an upload service that has no presign route (HTTP 404) the old route is used
602
+ * for a ZIP of up to 100 MiB, with a warning; a larger ZIP stops with
603
+ * "upload service too old for a ZIP this size". A failed PUT or complete marks
604
+ * the build failed (metadata/failed) and returns {success:false}.
504
605
  *
505
606
  * @param {axios.AxiosInstance} apiClient
506
607
  * @param {{project: string, version: string}} target
@@ -510,48 +611,93 @@ async function uploadCoverageReportDirectly(apiClient, target, coverageReport) {
510
611
  * build document. Sent as query parameters so the service can record which
511
612
  * commit a build's indexed rows came from (P13a). Omitted members are simply
512
613
  * not sent; the service treats their absence as unknown rather than guessing.
513
- * @returns {Promise<{success:boolean, status?:number, queued?:boolean, buildNumber?:number, zipKey?:string, error?:string}>}
614
+ * @param {{buildId?: string}} [known] The build the storybook ZIP created, used
615
+ * to mark it failed when the presign step itself fails.
616
+ * @returns {Promise<{success:boolean, status?:number, queued?:boolean, buildNumber?:number, zipKey?:string, error?:string, requestId?:string, markedFailed?:boolean}>}
514
617
  */
515
- async function uploadMetadataZip(apiClient, target, metadataZipPath, customLogger = logger, gitContext = {}) {
516
- const projectName = target.project || 'main';
517
- const versionName = target.version || 'latest';
618
+ async function uploadMetadataZip(apiClient, target, metadataZipPath, customLogger = logger, gitContext = {}, known = {}) {
518
619
  const query = new URLSearchParams();
519
620
  if (gitContext.commitSha) query.set('commitSha', gitContext.commitSha);
520
621
  if (gitContext.branch) query.set('branch', gitContext.branch);
521
- const suffix = query.toString() ? `?${query.toString()}` : '';
522
- const url = `/upload/${projectName}/${versionName}/metadata${suffix}`;
622
+ const queryString = query.toString() ? `?${query.toString()}` : '';
523
623
 
524
624
  customLogger.info('Uploading metadata ZIP...');
525
625
 
626
+ const stage = { name: 'read', buildId: known.buildId };
627
+ let fileBuffer;
628
+ let presigned;
526
629
  try {
527
- const fileBuffer = fs.readFileSync(metadataZipPath);
528
- const response = await postThroughService(apiClient, url, fileBuffer, {
529
- label: 'Metadata ZIP',
530
- bytes: fileBuffer.length,
531
- headers: { 'Content-Type': 'application/zip' },
532
- log: customLogger,
533
- });
630
+ fileBuffer = fs.readFileSync(metadataZipPath);
631
+ stage.name = 'presign';
632
+ presigned = await presignAndPutMetadataZip(apiClient, target, fileBuffer, stage);
633
+ } catch (error) {
634
+ if (stage.name === 'presign' && isPresignRouteMissing(error)) {
635
+ return uploadMetadataZipOldService(apiClient, target, fileBuffer, queryString, customLogger);
636
+ }
637
+ return failMetadataUpload(apiClient, target, stage, error, customLogger);
638
+ }
639
+ customLogger.info(`Metadata ZIP stored (${formatBytes(fileBuffer.length)}, build #${presigned.buildNumber ?? 'n/a'}).`);
534
640
 
641
+ stage.name = 'complete';
642
+ try {
643
+ const response = await postThroughService(
644
+ apiClient,
645
+ metadataUrl(target, '/complete', queryString),
646
+ { buildId: presigned.buildId, zipKey: presigned.zipKey },
647
+ { label: 'Metadata complete', bytes: 0, headers: { 'Content-Type': 'application/json' }, log: customLogger }
648
+ );
535
649
  const data = response.data || {};
536
650
  customLogger.success(
537
- `Metadata ZIP uploaded (build #${data.buildNumber ?? 'n/a'}, queued: ${Boolean(data.queued)})`
651
+ `Metadata ZIP uploaded (build #${data.buildNumber ?? presigned.buildNumber ?? 'n/a'}, queued: ${Boolean(data.queued)})`
538
652
  );
539
-
540
653
  return {
541
654
  success: true,
542
655
  status: response.status,
543
656
  queued: Boolean(data.queued),
544
- buildNumber: data.buildNumber,
545
- zipKey: data.zipKey,
657
+ buildNumber: data.buildNumber ?? presigned.buildNumber,
658
+ zipKey: data.zipKey || presigned.zipKey,
546
659
  };
660
+ } catch (error) {
661
+ return failMetadataUpload(apiClient, target, stage, error, customLogger);
662
+ }
663
+ }
664
+
665
+ /** The upload service has no presign route: the old route, or a clear stop. */
666
+ async function uploadMetadataZipOldService(apiClient, target, fileBuffer, queryString, customLogger) {
667
+ if (fileBuffer.length > METADATA_OLD_ROUTE_MAX_BYTES) {
668
+ const message =
669
+ `upload service too old for a ZIP this size (${formatBytes(fileBuffer.length)}; it has no presigned metadata upload ` +
670
+ `and its metadata route takes at most ${formatBytes(METADATA_OLD_ROUTE_MAX_BYTES)}). Update the upload service, then re-run.`;
671
+ customLogger.error(`Metadata ZIP upload failed: ${message}`);
672
+ return { success: false, error: message, markedFailed: false, markFailedUnsupported: true };
673
+ }
674
+ customLogger.warn(
675
+ 'The upload service has no presigned metadata upload (HTTP 404); using the old route ' +
676
+ `(ZIP of ${formatBytes(fileBuffer.length)}, limit ${formatBytes(METADATA_OLD_ROUTE_MAX_BYTES)}).`
677
+ );
678
+ try {
679
+ return await uploadMetadataZipViaWorker(apiClient, target, fileBuffer, queryString, customLogger);
547
680
  } catch (error) {
548
681
  const message = sanitizeServerText(error.response?.data?.error || error.message || 'Unknown error');
549
- const requestId = getRequestId(error);
550
682
  customLogger.error(`Metadata ZIP upload failed: ${message}`);
551
- return { success: false, error: message, requestId };
683
+ return { success: false, error: message, requestId: getRequestId(error), markedFailed: false, markFailedUnsupported: true };
552
684
  }
553
685
  }
554
686
 
687
+ /** A step of the presigned flow failed for good: say so, mark the build failed. */
688
+ async function failMetadataUpload(apiClient, target, stage, error, customLogger) {
689
+ // Never print the presigned URL (it carries a signature): an error from the
690
+ // PUT is described by status or code only.
691
+ const detail = stage.name === 'put' || stage.name === 'read'
692
+ ? sanitizeServerText(describeUploadError(error))
693
+ : sanitizeServerText(error.response?.data?.error || error.message || 'Unknown error');
694
+ const step = { read: 'reading the ZIP', presign: 'requesting the upload URL', put: 'sending the ZIP to storage', complete: 'completing the upload' }[stage.name];
695
+ const message = sanitizeServerText(`${step} failed: ${detail}`);
696
+ customLogger.error(`Metadata ZIP upload failed: ${message}`);
697
+ const markedFailed = await markMetadataUploadFailed(apiClient, target, stage.buildId, `metadata upload failed while ${step}: ${detail}`, customLogger);
698
+ return { success: false, error: message, requestId: getRequestId(error), markedFailed, buildId: stage.buildId };
699
+ }
700
+
555
701
  /**
556
702
  * Upload storybook zip plus optional coverage report.
557
703
  *
@@ -619,6 +765,7 @@ async function uploadBuild(apiClient, target, options) {
619
765
  options.metadataZipPath,
620
766
  logger,
621
767
  options.gitContext || {},
768
+ { buildId: zipUpload?.buildId },
622
769
  );
623
770
  }
624
771
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrymore/scry-deployer",
3
- "version": "0.12.0-next.20261006020714",
3
+ "version": "0.12.0",
4
4
  "description": "A CLI to automate the deployment of Storybook static builds.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bash
2
+ # AT-16: install skills/scry-native-capture-setup from this checkout with `npx skills add` into a
3
+ # throwaway HOME and project, then check the skill landed and its SKILL.md frontmatter parses.
4
+ # Usage: bash scripts/check-skill-install.sh [skill-name] (default: scry-native-capture-setup)
5
+ # Also fails if the skill names the old epinnock/scry-node repo. Needs network for `npx skills` (npm registry) the first time. Never touches the real HOME or repo.
6
+ set -euo pipefail
7
+ SKILL="${1:-scry-native-capture-setup}"
8
+ REPO="$(cd "$(dirname "$0")/.." && pwd)"
9
+ TMP="$(mktemp -d)"
10
+ trap 'rm -rf "$TMP"' EXIT
11
+ mkdir -p "$TMP/home" "$TMP/project"
12
+ git -C "$TMP/project" init -q .
13
+
14
+ # F14: the skill must name the canonical repo, never the old epinnock/ name.
15
+ if grep -rn "epinnock/scry-node" "$REPO/skills/$SKILL" >/dev/null 2>&1; then
16
+ echo "FAIL: skills/$SKILL still mentions epinnock/scry-node (use scryorg/scry-node)" >&2; exit 1
17
+ fi
18
+ grep -q "npx skills add scryorg/scry-node --skill $SKILL" "$REPO/skills/$SKILL/SKILL.md" \
19
+ || { echo "FAIL: SKILL.md does not show 'npx skills add scryorg/scry-node --skill $SKILL'" >&2; exit 1; }
20
+ echo "canonical install command: npx skills add scryorg/scry-node --skill $SKILL (this script installs from the local checkout)"
21
+
22
+ echo "install: npx skills add $REPO --skill $SKILL (HOME=$TMP/home)"
23
+ (cd "$TMP/project" && HOME="$TMP/home" npx --yes skills add "$REPO" --skill "$SKILL" -y >"$TMP/install.log" 2>&1) \
24
+ || { tail -20 "$TMP/install.log" >&2; echo "FAIL: skills add exited non-zero" >&2; exit 1; }
25
+
26
+ # The installer writes the canonical copy to .agents/skills/<name> and links Claude Code to it
27
+ # (other agents such as Amp get a rewritten copy under agent/skills; not checked here).
28
+ FOUND="$TMP/project/.agents/skills/$SKILL/SKILL.md"
29
+ [ -f "$FOUND" ] || { tail -20 "$TMP/install.log" >&2; echo "FAIL: .agents/skills/$SKILL/SKILL.md not found after install" >&2; exit 1; }
30
+ echo "landed: ${FOUND#"$TMP"/}"
31
+ [ -f "$TMP/project/.claude/skills/$SKILL/SKILL.md" ] || { echo "FAIL: Claude Code link .claude/skills/$SKILL missing" >&2; exit 1; }
32
+ echo "landed: project/.claude/skills/$SKILL/SKILL.md (Claude Code)"
33
+
34
+ # Only the requested skill may have been installed (no scry-setup riding along).
35
+ OTHERS="$(find "$TMP/project/.agents/skills" -mindepth 2 -maxdepth 2 -name SKILL.md ! -path "*/$SKILL/*" 2>/dev/null || true)"
36
+ [ -z "$OTHERS" ] || { echo "FAIL: unexpected extra skills installed: $OTHERS" >&2; exit 1; }
37
+
38
+ SKILL_MD="$FOUND" SKILL_NAME="$SKILL" node -e '
39
+ const fs = require("fs"), path = require("path");
40
+ const file = process.env.SKILL_MD, name = process.env.SKILL_NAME;
41
+ const text = fs.readFileSync(file, "utf8");
42
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(text);
43
+ if (!m) { console.error("FAIL: no YAML frontmatter"); process.exit(1); }
44
+ const fm = {};
45
+ for (const line of m[1].split(/\r?\n/)) { const k = /^([A-Za-z_-]+):\s*(.*)$/.exec(line); if (k) fm[k[1]] = k[2].trim(); }
46
+ if (fm.name !== name) { console.error(`FAIL: frontmatter name "${fm.name}" != "${name}"`); process.exit(1); }
47
+ if (!fm.description || fm.description.length < 40 || fm.description.length > 1024) { console.error("FAIL: description missing or outside 40..1024 chars"); process.exit(1); }
48
+ // every relative link in SKILL.md must resolve inside the installed skill
49
+ const dir = path.dirname(file), bad = [];
50
+ for (const l of text.matchAll(/\]\((?!https?:|#)([^)\s]+)\)/g)) if (!fs.existsSync(path.join(dir, l[1]))) bad.push(l[1]);
51
+ if (bad.length) { console.error("FAIL: broken relative links: " + bad.join(", ")); process.exit(1); }
52
+ const files = fs.readdirSync(dir, { recursive: true }).filter((f) => fs.statSync(path.join(dir, f)).isFile());
53
+ console.log(`frontmatter ok: name=${fm.name}, description ${fm.description.length} chars, ${files.length} files installed`);
54
+ '
55
+
56
+ # scry-native-capture-setup covers Flutter (feature flutter-capture): the Flutter files must have landed, the
57
+ # skill must not still refuse Flutter, and UIKit-only must still be refused with the pointer to bundle.md.
58
+ if [ "$SKILL" = scry-native-capture-setup ]; then
59
+ D="$TMP/project/.agents/skills/$SKILL"
60
+ for f in references/flutter.md references/bundle.md assets/make-scf.mjs assets/flutter/capture.sh assets/flutter/screens.dart \
61
+ assets/flutter/screens.json assets/flutter/scry_capture_test.dart assets/flutter/headless_scry_capture_test.dart \
62
+ assets/flutter/test_driver_integration_test.dart assets/flutter/scry_fonts.dart assets/flutter/scry_registry_test.dart; do
63
+ [ -f "$D/$f" ] || { echo "FAIL: $f missing from the installed skill" >&2; exit 1; }
64
+ done
65
+ if grep -Eqi 'not for[^.]*flutter|UIKit-only, Flutter' "$D/SKILL.md"; then
66
+ echo "FAIL: SKILL.md still refuses Flutter" >&2; exit 1
67
+ fi
68
+ grep -qi 'UIKit-only' "$D/SKILL.md" && grep -q 'references/bundle.md' "$D/SKILL.md" \
69
+ || { echo "FAIL: SKILL.md no longer refuses UIKit-only with the pointer to references/bundle.md" >&2; exit 1; }
70
+ grep -q 'references/flutter.md' "$D/SKILL.md" || { echo "FAIL: SKILL.md does not link references/flutter.md" >&2; exit 1; }
71
+ echo "flutter: reference and assets installed, Flutter no longer refused, UIKit-only still refused"
72
+ fi
73
+ echo "PASS: $SKILL installs and parses"
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # Drift check: the skill's assets/ are copies of the Kettle sample apps (the sample apps are canonical).
3
+ # Usage: bash scripts/check-skill-sync.sh <scry-sample-ios dir> <scry-sample-android dir> <scry-sample-flutter dir>
4
+ # Exit 0 = every copy matches its sample and the CI templates keep the G5 guards; 1 = drift (diff shown).
5
+ # The Flutter sample is the third argument; for the Flutter copies of make-scf.mjs (the sample's, iOS's and
6
+ # Android's) the rule is byte-identical to the skill asset (feature flutter-capture, guarantee 6); see the end.
7
+ set -euo pipefail
8
+ [ $# -eq 3 ] || { echo "usage: $0 <ios-sample-dir> <android-sample-dir> <flutter-sample-dir>" >&2; exit 2; }
9
+ IOS="$1"; AND="$2"; FLU="$3"
10
+ ASSETS="$(cd "$(dirname "$0")/../skills/scry-native-capture-setup/assets" && pwd)"
11
+ AK="$AND/app/src"; fail=0
12
+
13
+ check() { # <asset> <sample file>
14
+ if [ ! -f "$2" ]; then echo "MISSING sample file: $2"; fail=1
15
+ elif diff -u "$2" "$ASSETS/$1" >"${TMPDIR:-/tmp}/skill-sync.diff"; then echo "ok $1 == $2"
16
+ else echo "DRIFT $1 differs from $2"; cat "${TMPDIR:-/tmp}/skill-sync.diff"; fail=1; fi
17
+ }
18
+ check ScryLaunch.swift "$IOS/Kettle/ScryLaunch.swift"
19
+ check ScryScreens.swift "$IOS/Kettle/ScryScreens.swift"
20
+ check capture-ios.sh "$IOS/scripts/capture.sh"
21
+ check make-scf.mjs "$IOS/scripts/make-scf.mjs"
22
+ check scry-capture-ios.yml "$IOS/.github/workflows/scry-capture.yml"
23
+ check ScryLaunch.kt "$(ls "$AK"/debug/java/*/*/*/ScryLaunch.kt 2>/dev/null | head -1)"
24
+ check ScryScreens.kt "$(ls "$AK"/debug/java/*/*/*/ScryScreens.kt 2>/dev/null | head -1)"
25
+ check ScryLaunch-release.kt "$(ls "$AK"/release/java/*/*/*/ScryLaunch.kt 2>/dev/null | head -1)"
26
+ check capture-android.sh "$AND/scripts/capture.sh"
27
+ check scry-capture-android.yml "$AND/.github/workflows/scry-capture.yml"
28
+
29
+ # Flutter (the sample is canonical for logic; screens.dart and screens.json are templates the user fills in, so
30
+ # they only have to exist in both places).
31
+ F="$ASSETS/flutter"
32
+ check flutter/scry_capture_test.dart "$FLU/integration_test/scry_capture_test.dart"
33
+ check flutter/test_driver_integration_test.dart "$FLU/test_driver/integration_test.dart"
34
+ check flutter/headless_scry_capture_test.dart "$FLU/test/scry_capture_test.dart"
35
+ check flutter/capture.sh "$FLU/scripts/capture.sh"
36
+ check flutter/scry_fonts.dart "$FLU/test/scry_fonts.dart"
37
+ check flutter/scry_registry_test.dart "$FLU/test/scry_registry_test.dart"
38
+ for t in screens.dart screens.json; do [ -f "$F/$t" ] && echo "ok flutter/$t template present" || { echo "MISSING flutter/$t template"; fail=1; }; done
39
+ [ -f "$FLU/integration_test/scry/screens.dart" ] && [ -f "$FLU/scripts/screens.json" ] \
40
+ && echo "ok Flutter sample has its registry and screens.json" || { echo "MISSING Flutter sample registry or screens.json"; fail=1; }
41
+ check make-scf.mjs "$FLU/scripts/make-scf.mjs"
42
+ # make-scf.mjs is shared and must be byte-identical in the skill and all three samples (the iOS copy is checked above).
43
+ check make-scf.mjs "$AND/scripts/make-scf.mjs"
44
+
45
+ # G5 on the CI templates: default branch only, no pull_request(_target), no self-hosted, key only on upload.
46
+ for y in scry-capture-ios.yml scry-capture-android.yml; do
47
+ f="$ASSETS/$y"
48
+ grep -Eq '^\s*(pull_request|pull_request_target)\s*:' "$f" && { echo "G5 FAIL $y: pull_request trigger"; fail=1; }
49
+ grep -qi 'self-hosted' "$f" && { echo "G5 FAIL $y: self-hosted"; fail=1; }
50
+ grep -q 'branches: \[main\]' "$f" || { echo "G5 FAIL $y: not limited to a branch"; fail=1; }
51
+ n="$(grep -c 'secrets\.' "$f")"; [ "$n" -eq 1 ] || { echo "G5 FAIL $y: secrets referenced $n times (want 1, the upload step)"; fail=1; }
52
+ done
53
+ [ "$fail" -eq 0 ] && echo "PASS: skill assets match the samples, CI guards intact" || { echo "FAIL: drift found" >&2; exit 1; }