@fluid-app/fluid-cli-assets 0.1.339 → 0.1.341
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 +7 -6
- package/jest.config.cjs +14 -0
- package/jest.mocks/fluid-cli.ts +14 -0
- package/package.json +18 -3
- package/src/commands/__tests__/upload.test.ts +121 -0
- package/src/commands/upload.ts +11 -8
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
|
-
|
|
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
|
package/jest.config.cjs
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.1.341",
|
|
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.
|
|
20
|
+
"@fluid-app/fluid-cli": "0.1.353",
|
|
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
|
+
});
|
package/src/commands/upload.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
/**
|