@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 +11 -1
- package/bin/cli.js +11 -2
- package/lib/apiClient.js +169 -22
- package/package.json +1 -1
- package/scripts/check-skill-install.sh +73 -0
- package/scripts/check-skill-sync.sh +53 -0
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
|
-
|
|
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
|
-
*
|
|
503
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
@@ -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; }
|