@fluid-app/fluid-cli-assets 0.1.340 → 0.1.342

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/dist/index.mjs CHANGED
@@ -1,11 +1,10 @@
1
1
  import { createDomainCommand, declareInputs, findProjectConfig, getAuthToken } from "@fluid-app/fluid-cli";
2
2
  import "commander";
3
- import { readFile, stat } from "node:fs/promises";
4
3
  import { basename } from "node:path";
5
4
  //#region src/commands/upload.ts
6
5
  const UPLOAD_BASE = (process.env["FLUID_UPLOAD_BASE"] ?? "https://upload.fluid.app").replace(/\/$/, "");
7
6
  function registerUpload(parent, ctx) {
8
- parent.command("upload [file]").description("Upload a file or URL to the Fluid DAM (via upload.fluid.app)").option("--url <url>", "Fetch from an external URL instead of attaching a local file").option("--name <name>", "Display name for the DAM asset").option("--description <description>", "Asset description").option("--tags <tags>", "Comma-separated tags").option("--folder <folder>", "ImageKit folder override").option("--no-unique-filename", "Disable ImageKit's useUniqueFileName (default: enabled)").option("--create-media", "Also create a Fluid Media resource (active, share, publish-now)").action(async function(filePath, opts) {
7
+ declareInputs(parent.command("upload [file]").description("Upload a file or URL to the Fluid DAM (via upload.fluid.app)").option("--url <url>", "Fetch from an external URL instead of attaching a local file").option("--name <name>", "Display name for the DAM asset").option("--description <description>", "Asset description").option("--tags <tags>", "Comma-separated tags").option("--folder <folder>", "ImageKit folder override").option("--no-unique-filename", "Disable ImageKit's useUniqueFileName (default: enabled)").option("--create-media", "Also create a Fluid Media resource (active, share, publish-now)").action(async function(filePath, opts) {
9
8
  const token = resolveToken(this.parent?.opts() ?? {});
10
9
  if (!token) throw new Error("No API token found. Run `fluid login` or provide --token <token>.");
11
10
  if (!filePath && !opts.url) throw new Error("Provide a file path or --url <url>. Run `fluid dam upload --help` for usage.");
@@ -15,11 +14,10 @@ function registerUpload(parent, ctx) {
15
14
  if (opts.uniqueFilename === false) form.append("useUniqueFileName", "false");
16
15
  if (opts.url) form.append("external_asset_url", opts.url);
17
16
  else if (filePath) {
18
- await stat(filePath);
19
- const bytes = await readFile(filePath);
17
+ const bytes = ctx.inputs.readFile(filePath);
20
18
  const name = basename(filePath);
21
19
  form.append("fileName", name);
22
- form.append("file", new Blob([bytes]), name);
20
+ form.append("file", new Blob([new Uint8Array(bytes)]), name);
23
21
  }
24
22
  if (opts.name) form.append("name", opts.name);
25
23
  if (opts.description) form.append("description", opts.description);
@@ -40,7 +38,10 @@ function registerUpload(parent, ctx) {
40
38
  }
41
39
  const body = await resp.json();
42
40
  ctx.output(body);
43
- });
41
+ }), (_opts, args) => args[0] ? [{
42
+ kind: "file",
43
+ path: args[0]
44
+ }] : []);
44
45
  }
45
46
  /**
46
47
  * Token resolution mirrors `createCommandContext.resolveToken` from
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ ...(process.env.CI === "true" ? {} : { maxWorkers: "50%" }),
3
+ testEnvironment: "node",
4
+ testMatch: ["**/src/**/__tests__/**/*.test.ts"],
5
+ transform: {
6
+ "^.+\\.(t|j)sx?$": ["@swc/jest", { module: { type: "commonjs" } }],
7
+ },
8
+ moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
9
+ moduleNameMapper: {
10
+ // The real package only ships ESM, which Jest can't resolve in CJS mode.
11
+ "^@fluid-app/fluid-cli$": "<rootDir>/jest.mocks/fluid-cli.ts",
12
+ "^(\\.{1,2}/.*)\\.js$": "$1",
13
+ },
14
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Jest mock for @fluid-app/fluid-cli, which only ships ESM. The assets
3
+ * plugin's tests exercise its own modules.
4
+ */
5
+ export function getAuthToken(): string | null {
6
+ return null;
7
+ }
8
+
9
+ export function findProjectConfig(): null {
10
+ return null;
11
+ }
12
+
13
+ export * from "../../core/src/inputs/index";
14
+ export { createInputsReader } from "../../core/src/inputs/reader";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluid-app/fluid-cli-assets",
3
- "version": "0.1.340",
3
+ "version": "0.1.342",
4
4
  "description": "Fluid CLI commands for digital asset management (DAM)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -17,13 +17,17 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "@fluid-app/fluid-cli": "0.1.352",
20
+ "@fluid-app/fluid-cli": "0.1.354",
21
21
  "commander": "^12.0.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@fluid-app/api-client-scripts": "0.0.0",
25
25
  "@fluid-app/typescript-config": "0.0.0",
26
+ "@swc/core": "^1.15.18",
27
+ "@swc/jest": "^0.2.39",
28
+ "@types/jest": "^29.5.14",
26
29
  "@types/node": "24.10.12",
30
+ "jest": "^29.7.0",
27
31
  "tsdown": "^0.21.0",
28
32
  "tsx": "^4.21.0",
29
33
  "typescript": "^5"
@@ -46,6 +50,16 @@
46
50
  }
47
51
  ]
48
52
  },
53
+ "test": {
54
+ "dependsOn": [
55
+ {
56
+ "target": "build",
57
+ "projects": [
58
+ "@fluid-app/fluid-cli"
59
+ ]
60
+ }
61
+ ]
62
+ },
49
63
  "build": {
50
64
  "dependsOn": [
51
65
  "typecheck",
@@ -65,6 +79,7 @@
65
79
  "generate": "tsx scripts/generate.ts",
66
80
  "lint": "oxlint",
67
81
  "lint:fix": "oxlint --fix",
68
- "typecheck": "tsgo --noEmit"
82
+ "typecheck": "tsgo --noEmit",
83
+ "test": "jest"
69
84
  }
70
85
  }
@@ -0,0 +1,121 @@
1
+ import { mkdtempSync, realpathSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { Command } from "commander";
5
+ import * as cli from "@fluid-app/fluid-cli";
6
+ import {
7
+ guardUndeclaredReads,
8
+ registerInputsHook,
9
+ type CommandContext,
10
+ type CommandInputs,
11
+ } from "@fluid-app/fluid-cli";
12
+
13
+ const createInputsReader = (cli as unknown as { createInputsReader(): CommandInputs }).createInputsReader;
14
+
15
+ import { registerUpload } from "../upload";
16
+
17
+ class Exit extends Error {
18
+ constructor(readonly code: number) {
19
+ super(`exit ${code}`);
20
+ }
21
+ }
22
+
23
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "assets-inputs-")));
24
+ const photo = join(dir, "photo.jpg");
25
+ const RESOLVE = { FLUID_INPUTS_MODE: "resolve" };
26
+
27
+ async function drive(
28
+ env: NodeJS.ProcessEnv,
29
+ argv: string[],
30
+ afterInputs?: () => void,
31
+ ) {
32
+ const program = new Command("fluid").exitOverride();
33
+ const out = { stdout: "", stderr: "", code: null as number | null };
34
+ registerInputsHook(program, {
35
+ env,
36
+ stdout: (t) => (out.stdout += t),
37
+ stderr: (t) => (out.stderr += t),
38
+ exit: (code) => {
39
+ throw new Exit(code);
40
+ },
41
+ });
42
+ if (afterInputs) program.hook("preAction", afterInputs);
43
+ const ctx = {
44
+ inputs: createInputsReader(),
45
+ output: jest.fn(),
46
+ verbose: false,
47
+ } as unknown as CommandContext;
48
+ registerUpload(program.command("assets"), ctx);
49
+ try {
50
+ await program.parseAsync(["assets", "upload", ...argv], { from: "user" });
51
+ } catch (error) {
52
+ if (!(error instanceof Exit)) throw error;
53
+ out.code = error.code;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ describe("assets upload inputs", () => {
59
+ const fetchMock = jest.fn();
60
+ const realFetch = global.fetch;
61
+ const realToken = process.env["FLUID_TOKEN"];
62
+
63
+ beforeEach(() => {
64
+ writeFileSync(photo, "bytes-1");
65
+ fetchMock.mockReset();
66
+ fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 1 }) });
67
+ global.fetch = fetchMock as unknown as typeof fetch;
68
+ process.env["FLUID_TOKEN"] = "tok";
69
+ });
70
+
71
+ afterAll(() => {
72
+ global.fetch = realFetch;
73
+ if (realToken === undefined) delete process.env["FLUID_TOKEN"];
74
+ else process.env["FLUID_TOKEN"] = realToken;
75
+ });
76
+
77
+ it("resolves the path as a file input", async () => {
78
+ const out = await drive(RESOLVE, [photo]);
79
+ expect(out.code).toBe(0);
80
+ const manifest = JSON.parse(out.stdout);
81
+ expect(manifest.command).toBe("fluid assets upload");
82
+ expect(manifest.inputs).toEqual([
83
+ { kind: "file", path: photo, sha256: expect.any(String), size: 7 },
84
+ ]);
85
+ expect(fetchMock).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it("declares no input for --url", async () => {
89
+ const out = await drive(RESOLVE, ["--url", "https://example.test/a.jpg"]);
90
+ expect(JSON.parse(out.stdout).inputs).toEqual([]);
91
+ });
92
+
93
+ it("refuses a byte change made after approval", async () => {
94
+ const manifest = join(dir, "manifest.json");
95
+ writeFileSync(manifest, (await drive(RESOLVE, [photo])).stdout);
96
+ writeFileSync(photo, "bytes-2");
97
+ const out = await drive({ FLUID_INPUTS_EXPECT: manifest }, [photo]);
98
+ expect(out.code).toBe(65);
99
+ expect(out.stderr).toBe("inputs_mismatch\n");
100
+ expect(fetchMock).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it("uploads the approved bytes even if the file changes after the expect read", async () => {
104
+ const manifest = join(dir, "approved.json");
105
+ writeFileSync(manifest, (await drive(RESOLVE, [photo])).stdout);
106
+ const guard = guardUndeclaredReads();
107
+ let out;
108
+ try {
109
+ out = await drive({ FLUID_INPUTS_EXPECT: manifest }, [photo], () =>
110
+ writeFileSync(photo, "bytes-X"),
111
+ );
112
+ } finally {
113
+ guard.restore();
114
+ }
115
+ expect(out.code).toBeNull();
116
+ expect(guard.violations).toEqual([]);
117
+ const body = fetchMock.mock.calls[0]![1].body as FormData;
118
+ const sent = Buffer.from(await (body.get("file") as File).arrayBuffer());
119
+ expect(sent.toString()).toBe("bytes-1");
120
+ });
121
+ });
@@ -16,9 +16,12 @@
16
16
  * regular Fluid API and we just need the bearer to forward.
17
17
  */
18
18
  import { Command } from "commander";
19
- import { readFile, stat } from "node:fs/promises";
20
19
  import { basename } from "node:path";
21
- import { findProjectConfig, getAuthToken } from "@fluid-app/fluid-cli";
20
+ import {
21
+ declareInputs,
22
+ findProjectConfig,
23
+ getAuthToken,
24
+ } from "@fluid-app/fluid-cli";
22
25
  import type { CommandContext } from "@fluid-app/fluid-cli";
23
26
 
24
27
  // Hard-coded — the bearer token we forward is the user's Fluid API
@@ -47,7 +50,7 @@ interface UploadOptions {
47
50
  }
48
51
 
49
52
  export function registerUpload(parent: Command, ctx: CommandContext): void {
50
- parent
53
+ const upload = parent
51
54
  .command("upload [file]")
52
55
  .description("Upload a file or URL to the Fluid DAM (via upload.fluid.app)")
53
56
  .option(
@@ -111,16 +114,13 @@ export function registerUpload(parent: Command, ctx: CommandContext): void {
111
114
  if (opts.url) {
112
115
  form.append("external_asset_url", opts.url);
113
116
  } else if (filePath) {
114
- // Validate first — `readFile` errors are friendlier than the
115
- // service returning a generic 500 for a missing file.
116
- await stat(filePath);
117
- const bytes = await readFile(filePath);
117
+ const bytes = ctx.inputs.readFile(filePath);
118
118
  const name = basename(filePath);
119
119
  form.append("fileName", name);
120
120
  // Wrap as a Blob so the global fetch's multipart serializer
121
121
  // emits a proper Content-Disposition with filename. Node 20+
122
122
  // global FormData accepts (Blob, filename) pairs.
123
- form.append("file", new Blob([bytes]), name);
123
+ form.append("file", new Blob([new Uint8Array(bytes)]), name);
124
124
  }
125
125
 
126
126
  if (opts.name) form.append("name", opts.name);
@@ -155,6 +155,9 @@ export function registerUpload(parent: Command, ctx: CommandContext): void {
155
155
  const body: unknown = await resp.json();
156
156
  ctx.output(body);
157
157
  });
158
+ declareInputs(upload, (_opts, args) =>
159
+ args[0] ? [{ kind: "file", path: args[0] }] : [],
160
+ );
158
161
  }
159
162
 
160
163
  /**