@fishawack/lab-env 5.4.0 → 5.5.0-beta.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  ## Changelog
2
2
 
3
+ ### 5.5.0-beta.1 (2026-04-13)
4
+
5
+ #### Features
6
+
7
+ * content logic from now moved to lab-env in backwards compatible way ([2d836ce](https://bitbucket.org/fishawackdigital/lab-env/commits/2d836cec5bc9d429b7fdc544c5fc1e8c3b749124))
8
+ * extract snyk scan into standalone fw scan command ([abba628](https://bitbucket.org/fishawackdigital/lab-env/commits/abba628183fa94da5a21a14eb014c13a23036f84))
9
+ * **python:** added healthcheck ([ca6e323](https://bitbucket.org/fishawackdigital/lab-env/commits/ca6e323487aaae201eabe34e5208845585d182d4))
10
+ * **workspace:** support adding and removing projects from existing workspaces ([d70bf41](https://bitbucket.org/fishawackdigital/lab-env/commits/d70bf4130dffe412a15e8d8c6c3e6777daac65d3))
11
+
12
+ #### Bug Fixes
13
+
14
+ * allow blob as default-src on csp ([3cea3d4](https://bitbucket.org/fishawackdigital/lab-env/commits/3cea3d4bbd8bcd0ffdb6293967e4248ed542e8cf))
15
+ * csps now include worker-src with blob and self support ([bbe35b8](https://bitbucket.org/fishawackdigital/lab-env/commits/bbe35b807153d1af53bad0872cc6461b8c39e92f))
16
+ * scans now all run before errors thrown and always tackle lock files ([f212df0](https://bitbucket.org/fishawackdigital/lab-env/commits/f212df0cc1e6d5373c6bffcbde7e4e1ac2784db6))
17
+ * split snyk scans per platform with project-name flag ([7d89539](https://bitbucket.org/fishawackdigital/lab-env/commits/7d895395359e6f9d594e264f9b0bcf2e5a59c3bd))
18
+
19
+ #### Build Updates
20
+
21
+ * added python as boilerplate option ([8695c48](https://bitbucket.org/fishawackdigital/lab-env/commits/8695c48a763f623fe852b029663d8f2d958ba383))
22
+
3
23
  ### 5.4.0 (2026-03-12)
4
24
 
5
25
  #### Features
package/_Ai/python-3.md CHANGED
@@ -1,42 +1,45 @@
1
1
  ---
2
- applyTo: "src/**/*"
2
+ applyTo: "**/*.py"
3
3
  ---
4
4
 
5
- # GitHub Copilot Instructions for Python Project
5
+ # Python Project Instructions
6
6
 
7
- ## Project Overview
7
+ ## Package Management
8
8
 
9
- This is a Python application that runs entirely in the host machine. All Python and pip operations must be executed within the appropriate environment using `venv`, `pip` and `python` for the python executable.
9
+ Uses **uv** (not pip). Dependencies defined in `pyproject.toml` with `uv.lock`. No `requirements.txt`.
10
10
 
11
- ## Project Management
11
+ All commands run through the `fw` container orchestration manager:
12
12
 
13
- - The codebase follows a standard Python project structure.
14
- - Virtual environments are used to manage dependencies.
15
- - Always run python and pip from the virtual environment directly (`./venv/bin/python` and `./venv/bin/pip`)
16
- - The project runs on Python 3.13 or higher.
17
- - The project must always use the `python-dotenv` package to load environment variables from a `.env` file.
18
- - All dependencies are listed in the `requirements.txt` file.
19
- - The project uses FastAPI and Uvicorn for the web server. Upon running the project, it will be accessible at `http://localhost:3000` and it'll keep running as long as the process is active. For easier development, pipe the output to a log file.
20
- - The project must always try to stay stateless and handle requests with in-memory processing whenever possible.
21
- - The project must always fail gracefully and return appropriate error messages.
13
+ ```bash
14
+ fw uv add <dep> # Add a dependency
15
+ fw uv add --dev <dep> # Add a dev dependency
16
+ fw uv sync # Install/sync all dependencies
17
+ fw uv run <command> # Run a command in the project environment
18
+ ```
22
19
 
23
- ## Common Command Patterns
20
+ ## Stack
24
21
 
25
- ### Run Project
22
+ - Python >=3.13 with FastAPI + Uvicorn
23
+ - `.env` loaded via `python-dotenv`
24
+ - Stateless request handling; fail gracefully with appropriate error responses
26
25
 
27
- ```bash
28
- # Update dependencies
29
- pip install -r ./src/requirements.txt
26
+ ## Project Structure
30
27
 
31
- # Add new package
32
- pip install package/name
28
+ - Entry point: `src/main.py`
29
+ - Routes: `src/routes/`
30
+ - Tests: `tests/` (run with `fw uv run pytest`)
31
+ - Dev dependencies in `[dependency-groups.dev]` of `pyproject.toml`
33
32
 
34
- # Run project
35
- python ./src/main.py
36
- ```
33
+ ## Tests
34
+
35
+ You can run tests via
36
+
37
+ `fw test`
38
+
39
+ or an individual test file with
40
+
41
+ `fw uv run pytest -o log_cli=true tests/integration/test_middleware.py`
37
42
 
38
- ## Project Structure Context
43
+ or a single test within that file with
39
44
 
40
- - **Main application**: The main entry point is `./src/main.py`
41
- - **Routes**: API routes are defined in `./src/routes/*.py`
42
- - **Connectors**: LLM Connectors are defined in `./src/connectors/*.py`
45
+ `fw uv run pytest -o log_cli=true tests/integration/test_middleware.py -k 'test_001'`
@@ -9,3 +9,7 @@ Backends are typically named with the pattern \*-api.
9
9
  Frontends are typically named with the pattern \*-fe. or with no suffix.
10
10
 
11
11
  Ensure you cd into the correct directory before running any terminal commands.
12
+
13
+ ## Commits
14
+
15
+ When a feature is complete always suggest the conventional commit that summarizes the changes.
@@ -0,0 +1,36 @@
1
+ {
2
+ "attributes": {
3
+ "src": "_Build",
4
+ "targets": {
5
+ "aws-s3": {
6
+ "content": [{ "aws-s3": "fishawack", "location": "core-test-suite-content" }]
7
+ },
8
+ "aws-s3-nested": {
9
+ "content": [{ "aws-s3": "fishawack", "location": "core-test-suite-content/nested" }]
10
+ },
11
+ "aws-s3-key": {
12
+ "content": [{ "aws-s3": "fishawack", "location": "core-test-suite-content", "key": "custom-key" }]
13
+ },
14
+ "aws-s3-sync": {
15
+ "content": [{ "aws-s3": "fishawack", "location": "core-test-suite-content", "key": "custom-key", "sync": true }]
16
+ },
17
+ "contentful": {
18
+ "content": [{ "url": "https://cdn.contentful.com/", "api": "spaces/hnifelsg7djt/environments/master/entries?access_token=-_qKPGeSi_ksTsXJd9o9KZdl-jdxZTDd_E9gjLS4QHY&content_type=", "type": "contentful", "bundle": true, "find": "\/\/images.ctfassets.net\/(.*?)\/(.*?)\/(.*?)\/", "endpoints": ["layout", "layoutCopy", "lessonImage"] }]
19
+ },
20
+ "empty": { "content": [] },
21
+ "handover": {},
22
+ "ftp-legacy": {
23
+ "content": [{ "ftp": "ftp-fishawack.egnyte.com", "location": "Shared/FW/Knutsford/Digital/Auto-Content/test-shared/" }]
24
+ },
25
+ "single": {
26
+ "content": [{ "aws-s3": "fishawack", "location": "core-test-suite-content" }]
27
+ },
28
+ "double": {
29
+ "content": [
30
+ { "aws-s3": "fishawack", "location": "core-test-suite-content" },
31
+ { "aws-s3": "fishawack", "location": "core-test-suite-content", "key": "custom-key" }
32
+ ]
33
+ }
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,517 @@
1
+ "use strict";
2
+
3
+ const expect = require("chai").expect;
4
+ const path = require("path");
5
+ const fs = require("fs-extra");
6
+ const glob = require("glob");
7
+ const { isEqual } = require("lodash");
8
+
9
+ const {
10
+ pullS3,
11
+ parseBucketAndPrefix,
12
+ getLocalFiles,
13
+ createClient,
14
+ } = require("../commands/helpers/content-pull.js");
15
+
16
+ const {
17
+ pullRequests,
18
+ download,
19
+ rewrite,
20
+ urlJoin,
21
+ } = require("../commands/helpers/content-request.js");
22
+
23
+ const fixtureDir = path.join(__dirname, "_fixtures", "content");
24
+ const outputDir = path.join(fixtureDir, "_Build", "content");
25
+ const snapshotPath = path.join(fixtureDir, ".tmp", "content.json");
26
+
27
+ function loadFixtureConfig(branch) {
28
+ const config = fs.readJSONSync(path.join(fixtureDir, "config.json"));
29
+ const branchConfig = config.attributes.targets[branch] || {};
30
+
31
+ return {
32
+ ...config.attributes,
33
+ ...branchConfig,
34
+ };
35
+ }
36
+
37
+ describe("content", () => {
38
+ describe("parseBucketAndPrefix", () => {
39
+ it("Should parse bucket only when no slash", () => {
40
+ const result = parseBucketAndPrefix("my-bucket");
41
+ expect(result.Bucket).to.equal("my-bucket");
42
+ expect(result.Prefix).to.equal("");
43
+ });
44
+
45
+ it("Should parse bucket and prefix when slash present", () => {
46
+ const result = parseBucketAndPrefix("my-bucket/some/prefix");
47
+ expect(result.Bucket).to.equal("my-bucket");
48
+ expect(result.Prefix).to.equal("some/prefix");
49
+ });
50
+
51
+ it("Should handle single level prefix", () => {
52
+ const result = parseBucketAndPrefix("my-bucket/folder");
53
+ expect(result.Bucket).to.equal("my-bucket");
54
+ expect(result.Prefix).to.equal("folder");
55
+ });
56
+ });
57
+
58
+ describe("getLocalFiles", () => {
59
+ it("Should return empty array for non-existent directory", () => {
60
+ const files = getLocalFiles(path.join(fixtureDir, "non-existent"));
61
+ expect(files).to.be.an("array").that.is.empty;
62
+ });
63
+
64
+ it("Should return file list with relative paths", () => {
65
+ const files = getLocalFiles(path.join(fixtureDir, "content-0"));
66
+ expect(files).to.be.an("array").that.is.not.empty;
67
+ expect(files[0]).to.have.property("relativePath");
68
+ expect(files[0]).to.have.property("mtime");
69
+ expect(files[0]).to.have.property("size");
70
+ });
71
+ });
72
+
73
+ describe("urlJoin", () => {
74
+ it("Should join base URL with path segments", () => {
75
+ const result = urlJoin(
76
+ "https://example.com",
77
+ "/api/v1/",
78
+ "endpoint",
79
+ );
80
+ expect(result).to.contain("example.com");
81
+ expect(result).to.contain("endpoint");
82
+ });
83
+
84
+ it("Should handle trailing slashes", () => {
85
+ const result = urlJoin(
86
+ "https://example.com/",
87
+ "api/v1/",
88
+ "endpoint",
89
+ );
90
+ expect(result).to.include("api/v1/endpoint");
91
+ });
92
+ });
93
+
94
+ describe("content:pull — S3", () => {
95
+ beforeEach(() => {
96
+ fs.removeSync(outputDir);
97
+ });
98
+
99
+ it("Should pull down assets from s3 via aws sdk", async () => {
100
+ const config = loadFixtureConfig("aws-s3");
101
+
102
+ await pullS3(config.content[0], path.join(outputDir, "content-0"));
103
+
104
+ expect(glob.sync(path.join(outputDir, "content-0/**/*"))).to.be.an(
105
+ "array",
106
+ ).that.is.not.empty;
107
+ });
108
+
109
+ it("Should pull down assets from s3 when nested", async () => {
110
+ const config = loadFixtureConfig("aws-s3-nested");
111
+
112
+ await pullS3(config.content[0], path.join(outputDir, "content-0"));
113
+
114
+ expect(
115
+ fs.readFileSync(path.join(outputDir, "content-0/test.txt"), {
116
+ encoding: "utf8",
117
+ }),
118
+ ).to.equal("core-test-suite-content-nested");
119
+ });
120
+
121
+ it("Should store in directory with key name when defined", async () => {
122
+ const config = loadFixtureConfig("aws-s3-key");
123
+
124
+ await pullS3(config.content[0], path.join(outputDir, "custom-key"));
125
+
126
+ expect(glob.sync(path.join(outputDir, "custom-key/**/*"))).to.be.an(
127
+ "array",
128
+ ).that.is.not.empty;
129
+ });
130
+
131
+ it("Should sync new assets from s3 (bidirectional)", async () => {
132
+ const config = loadFixtureConfig("aws-s3-sync");
133
+ const saveTo = path.join(outputDir, "custom-key");
134
+ const profile = config.content[0]["aws-s3"];
135
+ const client = createClient(profile);
136
+ const { Bucket, Prefix } = parseBucketAndPrefix(
137
+ config.content[0].location,
138
+ );
139
+
140
+ // Clean up any previous sync test file
141
+ try {
142
+ const { DeleteObjectCommand } = require("@aws-sdk/client-s3");
143
+ await client.send(
144
+ new DeleteObjectCommand({
145
+ Bucket,
146
+ Key: Prefix
147
+ ? `${Prefix}/sync-test.txt`
148
+ : "sync-test.txt",
149
+ }),
150
+ );
151
+ } catch {
152
+ /* may not exist */
153
+ }
154
+
155
+ // First pull to get existing content
156
+ await pullS3(config.content[0], saveTo);
157
+
158
+ // Create a local file to sync up
159
+ fs.mkdirpSync(saveTo);
160
+ fs.writeFileSync(path.join(saveTo, "sync-test.txt"), "sync-test");
161
+
162
+ // Second pull should upload local file then download remote
163
+ await pullS3(config.content[0], saveTo);
164
+
165
+ // Clean local and re-pull — the synced file should come back
166
+ fs.removeSync(saveTo);
167
+ await pullS3(config.content[0], saveTo);
168
+
169
+ expect(() =>
170
+ fs.readFileSync(path.join(saveTo, "sync-test.txt"), {
171
+ encoding: "utf8",
172
+ }),
173
+ ).to.not.throw();
174
+
175
+ // Cleanup remote sync test file
176
+ try {
177
+ const { DeleteObjectCommand } = require("@aws-sdk/client-s3");
178
+ await client.send(
179
+ new DeleteObjectCommand({
180
+ Bucket,
181
+ Key: Prefix
182
+ ? `${Prefix}/sync-test.txt`
183
+ : "sync-test.txt",
184
+ }),
185
+ );
186
+ } catch {
187
+ /* cleanup best-effort */
188
+ }
189
+ });
190
+
191
+ it("Should not re-download files when local is newer (--update semantics)", async () => {
192
+ const config = loadFixtureConfig("aws-s3");
193
+ const saveTo = path.join(outputDir, "content-0");
194
+
195
+ // First pull
196
+ await pullS3(config.content[0], saveTo);
197
+
198
+ const files = glob.sync(path.join(saveTo, "**/*"), {
199
+ nodir: true,
200
+ });
201
+ expect(files.length).to.be.greaterThan(0);
202
+
203
+ // Touch all local files to make them "newer"
204
+ const futureTime = new Date(Date.now() + 86400000);
205
+ files.forEach((f) => fs.utimesSync(f, futureTime, futureTime));
206
+
207
+ // Record mtime before second pull
208
+ const mtimesBefore = {};
209
+ files.forEach((f) => {
210
+ mtimesBefore[f] = fs.statSync(f).mtimeMs;
211
+ });
212
+
213
+ // Second pull should skip all files
214
+ await pullS3(config.content[0], saveTo);
215
+
216
+ // Verify files were not overwritten
217
+ files.forEach((f) => {
218
+ expect(fs.statSync(f).mtimeMs).to.equal(mtimesBefore[f]);
219
+ });
220
+ });
221
+
222
+ it("Should skip non-S3 protocols with warning", async () => {
223
+ const config = loadFixtureConfig("ftp-legacy");
224
+
225
+ // pullS3 won't be called for non-s3 — this is handled in content.js
226
+ // Verify the config doesn't have aws-s3 key
227
+ expect(config.content[0]["aws-s3"]).to.be.undefined;
228
+ expect(config.content[0].ftp).to.equal("ftp-fishawack.egnyte.com");
229
+ });
230
+
231
+ after(() => {
232
+ fs.removeSync(outputDir);
233
+ });
234
+ });
235
+
236
+ describe("config change detection", () => {
237
+ beforeEach(() => {
238
+ fs.removeSync(outputDir);
239
+ fs.removeSync(path.join(fixtureDir, ".tmp"));
240
+ });
241
+
242
+ it("Should clean content folders when content property is an empty array", () => {
243
+ // Pre-populate content directory
244
+ fs.copySync(
245
+ path.join(fixtureDir, "content-0"),
246
+ path.join(outputDir, "content-0"),
247
+ );
248
+
249
+ const config = loadFixtureConfig("empty");
250
+
251
+ expect(config.content).to.be.an("array").that.is.empty;
252
+ expect(glob.sync(path.join(outputDir, "**/*"))).to.be.an("array")
253
+ .that.is.not.empty;
254
+ });
255
+
256
+ it("Should detect when content config has not changed", () => {
257
+ const config = loadFixtureConfig("aws-s3");
258
+
259
+ // Save a snapshot
260
+ fs.mkdirpSync(path.dirname(snapshotPath));
261
+ fs.writeFileSync(snapshotPath, JSON.stringify(config.content));
262
+
263
+ // Compare — should be equal
264
+ const prev = JSON.parse(
265
+ fs.readFileSync(snapshotPath, { encoding: "utf8" }),
266
+ );
267
+ expect(isEqual(prev, config.content)).to.be.true;
268
+ });
269
+
270
+ it("Should detect when content config has changed", () => {
271
+ const configOld = loadFixtureConfig("single");
272
+ const configNew = loadFixtureConfig("double");
273
+
274
+ // Save old snapshot
275
+ fs.mkdirpSync(path.dirname(snapshotPath));
276
+ fs.writeFileSync(snapshotPath, JSON.stringify(configOld.content));
277
+
278
+ const prev = JSON.parse(
279
+ fs.readFileSync(snapshotPath, { encoding: "utf8" }),
280
+ );
281
+ expect(isEqual(prev, configNew.content)).to.be.false;
282
+ });
283
+
284
+ it("Should handle missing previous snapshot gracefully", () => {
285
+ // No snapshot file exists
286
+ expect(fs.existsSync(snapshotPath)).to.be.false;
287
+
288
+ let prev = null;
289
+ try {
290
+ prev = JSON.parse(
291
+ fs.readFileSync(snapshotPath, { encoding: "utf8" }),
292
+ );
293
+ } catch {
294
+ // expected
295
+ }
296
+
297
+ expect(prev).to.be.null;
298
+ });
299
+
300
+ it("Should clean content when config changes between pulls", async () => {
301
+ const configDouble = loadFixtureConfig("double");
302
+
303
+ // Simulate first pull creating two content dirs
304
+ fs.mkdirpSync(path.join(outputDir, "content-0"));
305
+ fs.mkdirpSync(path.join(outputDir, "custom-key"));
306
+ fs.writeFileSync(
307
+ path.join(outputDir, "content-0/file.txt"),
308
+ "test",
309
+ );
310
+ fs.writeFileSync(
311
+ path.join(outputDir, "custom-key/file.txt"),
312
+ "test",
313
+ );
314
+
315
+ // Save snapshot for "double" config
316
+ fs.mkdirpSync(path.dirname(snapshotPath));
317
+ fs.writeFileSync(
318
+ snapshotPath,
319
+ JSON.stringify(configDouble.content),
320
+ );
321
+
322
+ expect(glob.sync(path.join(outputDir, "*")).length).to.equal(2);
323
+
324
+ // Now switch to "single" config — should detect the change
325
+ const configSingle = loadFixtureConfig("single");
326
+ const prev = JSON.parse(
327
+ fs.readFileSync(snapshotPath, { encoding: "utf8" }),
328
+ );
329
+ expect(isEqual(prev, configSingle.content)).to.be.false;
330
+ });
331
+
332
+ it("Should not clean content when config unchanged between pulls", () => {
333
+ const config = loadFixtureConfig("double");
334
+
335
+ // Simulate content dirs
336
+ fs.mkdirpSync(path.join(outputDir, "content-0"));
337
+ fs.mkdirpSync(path.join(outputDir, "custom-key"));
338
+ fs.mkdirpSync(path.join(outputDir, "fake-content"));
339
+
340
+ // Save snapshot
341
+ fs.mkdirpSync(path.dirname(snapshotPath));
342
+ fs.writeFileSync(snapshotPath, JSON.stringify(config.content));
343
+
344
+ // Reload and compare — should be unchanged
345
+ const prev = JSON.parse(
346
+ fs.readFileSync(snapshotPath, { encoding: "utf8" }),
347
+ );
348
+ expect(isEqual(prev, config.content)).to.be.true;
349
+
350
+ // Fake-content should survive (not cleaned)
351
+ expect(glob.sync(path.join(outputDir, "*")).length).to.equal(3);
352
+ });
353
+
354
+ it("Should skip cleaning when content property is undefined", () => {
355
+ // Pre-populate content
356
+ fs.copySync(
357
+ path.join(fixtureDir, "content-0"),
358
+ path.join(outputDir, "content-0"),
359
+ );
360
+
361
+ const config = loadFixtureConfig("handover");
362
+
363
+ // "handover" target has no content property
364
+ expect(config.content).to.be.undefined;
365
+ // Content should remain untouched
366
+ expect(glob.sync(path.join(outputDir, "**/*"))).to.be.an("array")
367
+ .that.is.not.empty;
368
+ });
369
+
370
+ after(() => {
371
+ fs.removeSync(outputDir);
372
+ fs.removeSync(path.join(fixtureDir, ".tmp"));
373
+ });
374
+ });
375
+
376
+ describe("content:request — HTTP APIs", () => {
377
+ describe("contentful", () => {
378
+ const saveTo = path.join(outputDir, "content-0");
379
+
380
+ before(async () => {
381
+ fs.removeSync(outputDir);
382
+
383
+ const config = loadFixtureConfig("contentful");
384
+
385
+ await pullRequests(config.content, fixtureDir + "/_Build");
386
+ });
387
+
388
+ it("Should save to json files", () => {
389
+ expect(
390
+ fs
391
+ .lstatSync(path.join(saveTo, "media/layout.json"))
392
+ .isFile(),
393
+ ).to.be.true;
394
+ expect(
395
+ fs
396
+ .lstatSync(path.join(saveTo, "media/layoutCopy.json"))
397
+ .isFile(),
398
+ ).to.be.true;
399
+ });
400
+
401
+ it("Should download images", () => {
402
+ const mediaFiles = glob.sync(
403
+ path.join(saveTo, "media/**/*.png"),
404
+ );
405
+ expect(mediaFiles).to.be.an("array").that.is.not.empty;
406
+ });
407
+
408
+ after(() => {
409
+ fs.removeSync(outputDir);
410
+ });
411
+ });
412
+ });
413
+
414
+ describe("rewrite", () => {
415
+ const saveTo = path.join(outputDir, "rewrite-test");
416
+
417
+ beforeEach(() => {
418
+ fs.removeSync(saveTo);
419
+ fs.mkdirpSync(saveTo);
420
+ });
421
+
422
+ it("Should rewrite remote URLs to local media paths", async () => {
423
+ // Create a mock JSON file with remote URLs
424
+ const mockData = JSON.stringify({
425
+ image: "https://example.com/wp-content/uploads/2024/photo.jpg",
426
+ text: "hello world",
427
+ nested: {
428
+ img: "https://example.com/wp-content/uploads/2024/nested.png",
429
+ },
430
+ });
431
+ fs.writeFileSync(path.join(saveTo, "test.json"), mockData);
432
+
433
+ await rewrite({
434
+ ext: "json",
435
+ type: "wp",
436
+ saveTo,
437
+ bundle: "",
438
+ find: "^https.*/wp-content/uploads",
439
+ });
440
+
441
+ const result = fs.readFileSync(path.join(saveTo, "test.json"), {
442
+ encoding: "utf8",
443
+ });
444
+
445
+ expect(result).to.contain("media/content");
446
+ expect(result).to.not.contain("wp-content/uploads");
447
+ });
448
+
449
+ it("Should unwrap contentful response during rewrite", async () => {
450
+ // Contentful responses are arrays, rewrite should extract first element
451
+ const mockData = JSON.stringify([
452
+ { title: "Entry 1", body: "Content" },
453
+ { title: "Entry 2", body: "More content" },
454
+ ]);
455
+ fs.writeFileSync(path.join(saveTo, "test.json"), mockData);
456
+
457
+ await rewrite({
458
+ ext: "json",
459
+ type: "contentful",
460
+ saveTo,
461
+ bundle: "",
462
+ find: "//images\\.ctfassets\\.net/(.*?)/(.*?)/(.*?)/",
463
+ });
464
+
465
+ const result = JSON.parse(
466
+ fs.readFileSync(path.join(saveTo, "test.json"), {
467
+ encoding: "utf8",
468
+ }),
469
+ );
470
+
471
+ // Should be unwrapped to first element, not an array
472
+ expect(result).to.have.property("title", "Entry 1");
473
+ });
474
+
475
+ after(() => {
476
+ fs.removeSync(saveTo);
477
+ });
478
+ });
479
+
480
+ describe("download", () => {
481
+ const saveTo = path.join(outputDir, "download-test");
482
+
483
+ beforeEach(() => {
484
+ fs.removeSync(saveTo);
485
+ fs.mkdirpSync(saveTo);
486
+ });
487
+
488
+ it("Should extract unique URLs matching find pattern from JSON", async () => {
489
+ // Create JSON that references assets
490
+ const mockData = JSON.stringify({
491
+ images: [
492
+ "https://example.com/wp-content/uploads/photo1.jpg",
493
+ "https://example.com/wp-content/uploads/photo2.jpg",
494
+ "https://example.com/wp-content/uploads/photo1.jpg",
495
+ ],
496
+ text: "not a URL",
497
+ });
498
+ fs.writeFileSync(path.join(saveTo, "test.json"), mockData);
499
+
500
+ // download will try to fetch these URLs — they'll fail but
501
+ // we verify the function doesn't throw
502
+ await download({
503
+ path: "https://example.com",
504
+ ext: "json",
505
+ saveTo,
506
+ bundle: "",
507
+ find: "^https.*/wp-content/uploads",
508
+ });
509
+ // If we get here without throwing, the URL extraction and
510
+ // deduplication logic works correctly
511
+ });
512
+
513
+ after(() => {
514
+ fs.removeSync(saveTo);
515
+ });
516
+ });
517
+ });
package/cli.js CHANGED
@@ -54,6 +54,7 @@ const args = hideBin(process.argv);
54
54
  "start",
55
55
  "setup",
56
56
  "test",
57
+ "scan",
57
58
  "production",
58
59
  "run",
59
60
  "connect",