@manta-eu/upload 0.1.2 → 0.1.4
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 -10
- package/dist/cli.js +13 -7
- package/dist/graphql.d.ts +2 -2
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +9 -9
- package/dist/upload.d.ts +1 -1
- package/dist/upload.d.ts.map +1 -1
- package/dist/upload.js +6 -6
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -14,24 +14,25 @@ pnpm manta-upload people.csv orders.parquet \
|
|
|
14
14
|
--api-url https://app.example \
|
|
15
15
|
--datastore your-datastore \
|
|
16
16
|
--keycloak-url https://keycloak.example \
|
|
17
|
-
--keycloak-realm your-realm
|
|
18
|
-
--client-id your-realm-app
|
|
17
|
+
--keycloak-realm your-realm
|
|
19
18
|
```
|
|
20
19
|
|
|
21
20
|
For each file the CLI prints the file's IRI, its media type and its size.
|
|
22
21
|
|
|
23
22
|
## Log in
|
|
24
23
|
|
|
25
|
-
Login works as it does for `manta-codegen
|
|
26
|
-
`MANTA_CLIENT_SECRET` decides the path.
|
|
24
|
+
Login works as it does for `manta-codegen`: both CLIs register the same commands from
|
|
25
|
+
`@manta-eu/auth`. `MANTA_CLIENT_SECRET` decides the path.
|
|
27
26
|
|
|
28
|
-
**No secret: a person.**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
**No secret: a person.** Log in once with `manta-upload login`, which takes the same
|
|
28
|
+
`--keycloak-url` and `--keycloak-realm`; `status` and `logout` sit next to it. The
|
|
29
|
+
upload is attributed to that person. The login is stored and renewed silently, and it is the same
|
|
30
|
+
stored login `manta-codegen` uses, so logging in with either CLI serves both. See
|
|
31
|
+
[`@manta-eu/auth`](https://www.npmjs.com/package/@manta-eu/auth) for how the browser login
|
|
32
|
+
works, where the login is stored and how long it lasts.
|
|
32
33
|
|
|
33
|
-
**A secret: a machine.** The CLI asks for a service account token
|
|
34
|
-
token.
|
|
34
|
+
**A secret: a machine.** The CLI asks for a service account token, from the realm's `manta-codegen`
|
|
35
|
+
client unless `--client-id` names another. Add `--scope` for a narrower token.
|
|
35
36
|
|
|
36
37
|
```shell
|
|
37
38
|
export MANTA_CLIENT_SECRET=... # never a flag: an argument list is public on the machine
|
package/dist/cli.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { cachedToken,
|
|
2
|
+
import { cachedToken, ciClientId, clientIdHelp, describeError, mintWithClientCredentials, publicClientFrom, registerAuthCommands, userTokenSource, } from "@manta-eu/auth";
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { graphqlClient, graphqlEndpoint } from "./graphql.js";
|
|
5
5
|
import { uploadFile } from "./upload.js";
|
|
6
6
|
// The secret stays out of the flags: an argument list is readable to every process on the machine.
|
|
7
7
|
function tokenSourceFor(options) {
|
|
8
|
-
const client = { baseUrl: options.keycloakUrl, realm: options.keycloakRealm, clientId: options.clientId };
|
|
9
8
|
const secret = process.env.MANTA_CLIENT_SECRET;
|
|
10
9
|
if (secret) {
|
|
10
|
+
const client = publicClientFrom({ ...options, clientId: options.clientId ?? ciClientId });
|
|
11
11
|
return cachedToken(() => mintWithClientCredentials({ ...client, clientSecret: secret, scope: options.scope }));
|
|
12
12
|
}
|
|
13
|
-
//
|
|
14
|
-
return
|
|
13
|
+
// The same stored login manta-codegen uses, renewed silently, so a long batch never stops for a browser.
|
|
14
|
+
return userTokenSource(publicClientFrom(options), { label: "manta-upload", useDeviceCode: options.useDeviceCode });
|
|
15
15
|
}
|
|
16
16
|
async function upload(paths, options) {
|
|
17
17
|
const graphql = graphqlClient({
|
|
@@ -38,21 +38,27 @@ async function upload(paths, options) {
|
|
|
38
38
|
}
|
|
39
39
|
const program = new Command()
|
|
40
40
|
.name("manta-upload")
|
|
41
|
-
.description("Upload files to a Manta datastore. CSV, Parquet, Turtle and N-Triples files are ingested into its knowledge graph.")
|
|
41
|
+
.description("Upload files to a Manta datastore. CSV, Parquet, Turtle and N-Triples files are ingested into its knowledge graph.");
|
|
42
|
+
// The default command, so `manta-upload <files...>` keeps working next to login, logout and status.
|
|
43
|
+
program
|
|
44
|
+
.command("upload", { isDefault: true })
|
|
45
|
+
.description("upload the files; the default when no other command is named")
|
|
42
46
|
.argument("<files...>", "files to upload")
|
|
43
47
|
.requiredOption("--api-url <url>", "Manta API base URL")
|
|
44
48
|
.requiredOption("--datastore <id>", "datastore to upload into")
|
|
45
49
|
.requiredOption("--keycloak-url <url>", "Keycloak base URL")
|
|
46
50
|
.requiredOption("--keycloak-realm <realm>", "Keycloak realm")
|
|
47
|
-
.
|
|
51
|
+
.option("--client-id <id>", clientIdHelp)
|
|
48
52
|
.option("--scope <scope>", "scope to ask a service account token for")
|
|
53
|
+
.option("--use-device-code", "if a login is needed, use a one-time code instead of a browser redirect")
|
|
49
54
|
.action(async (paths, options) => {
|
|
50
55
|
try {
|
|
51
56
|
await upload(paths, options);
|
|
52
57
|
}
|
|
53
58
|
catch (error) {
|
|
54
|
-
console.error(describeError(error));
|
|
59
|
+
console.error(`manta-upload: ${describeError(error)}`);
|
|
55
60
|
process.exitCode = 1;
|
|
56
61
|
}
|
|
57
62
|
});
|
|
63
|
+
registerAuthCommands(program, { label: "manta-upload" });
|
|
58
64
|
await program.parseAsync();
|
package/dist/graphql.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { type FetchLike } from "@manta-eu/
|
|
1
|
+
import { type FetchLike, type TokenSource } from "@manta-eu/auth";
|
|
2
2
|
export declare function graphqlEndpoint(apiUrl: string): string;
|
|
3
3
|
export type GraphqlClient = <T>(query: string, variables: Record<string, unknown>) => Promise<T>;
|
|
4
4
|
export declare function graphqlClient(options: {
|
|
5
5
|
endpoint: string;
|
|
6
6
|
datastore: string;
|
|
7
|
-
token:
|
|
7
|
+
token: TokenSource;
|
|
8
8
|
fetchImpl?: FetchLike;
|
|
9
9
|
}): GraphqlClient;
|
|
10
10
|
//# sourceMappingURL=graphql.d.ts.map
|
package/dist/graphql.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"graphql.d.ts","sourceRoot":"","sources":["../src/graphql.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"graphql.d.ts","sourceRoot":"","sources":["../src/graphql.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,SAAS,EAGd,KAAK,WAAW,EAEjB,MAAM,gBAAgB,CAAC;AAExB,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;AAGjG,wBAAgB,aAAa,CAAC,OAAO,EAAE;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,WAAW,CAAC;IACnB,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB,GAAG,aAAa,CAmChB"}
|
package/dist/graphql.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { describeError, readJson } from "@manta-eu/
|
|
1
|
+
import { describeError, readJson, RefusedRequestError, withRenewal, } from "@manta-eu/auth";
|
|
2
2
|
export function graphqlEndpoint(apiUrl) {
|
|
3
3
|
return `${apiUrl.replace(/\/$/, "")}/graphql`;
|
|
4
4
|
}
|
|
5
5
|
// The datastore travels as a header: the API refuses the call rather than fall back to another one.
|
|
6
6
|
export function graphqlClient(options) {
|
|
7
7
|
const { endpoint, datastore, token, fetchImpl = fetch } = options;
|
|
8
|
-
return
|
|
9
|
-
|
|
10
|
-
const authorization = `Bearer ${
|
|
8
|
+
return (query, variables) => withRenewal(token, (accessToken) => send(accessToken, query, variables));
|
|
9
|
+
async function send(accessToken, query, variables) {
|
|
10
|
+
const authorization = `Bearer ${accessToken}`;
|
|
11
11
|
let response;
|
|
12
12
|
try {
|
|
13
13
|
response = await fetchImpl(endpoint, {
|
|
@@ -17,17 +17,17 @@ export function graphqlClient(options) {
|
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
19
|
catch (cause) {
|
|
20
|
-
throw new Error(`
|
|
20
|
+
throw new Error(`cannot reach ${endpoint}. ${describeError(cause)}`, { cause });
|
|
21
21
|
}
|
|
22
|
-
const body = await readJson(response, endpoint
|
|
22
|
+
const body = await readJson(response, endpoint);
|
|
23
23
|
if (body.errors?.length) {
|
|
24
|
-
throw new Error(
|
|
24
|
+
throw new Error(`${body.errors.map((error) => error.message).join("; ")}`);
|
|
25
25
|
}
|
|
26
26
|
if (!response.ok || !body.data) {
|
|
27
27
|
// The Worker's auth middleware answers before Yoga, so its reason is `error`, not `errors`.
|
|
28
28
|
const reason = typeof body.error === "string" ? `: ${body.error}` : " without data";
|
|
29
|
-
throw new
|
|
29
|
+
throw new RefusedRequestError(`${endpoint} answered status ${response.status}${reason}`, response.status);
|
|
30
30
|
}
|
|
31
31
|
return body.data;
|
|
32
|
-
}
|
|
32
|
+
}
|
|
33
33
|
}
|
package/dist/upload.d.ts
CHANGED
package/dist/upload.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,SAAS,EAAqB,MAAM,
|
|
1
|
+
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,SAAS,EAAqB,MAAM,gBAAgB,CAAC;AACnE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAGlD,eAAO,MAAM,cAAc,QAAkB,CAAC;AAoB9C,MAAM,MAAM,YAAY,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAExG,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC5G,CAAC;AAGF,wBAAsB,SAAS,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAapG;AAED,MAAM,MAAM,KAAK,GAAG,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAiDlD,wBAAsB,UAAU,CAAC,OAAO,EAAE;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,aAAa,CAAC;IACvB,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,KAAK,CAAC,EAAE,KAAK,CAAC;CACf,GAAG,OAAO,CAAC,YAAY,CAAC,CASxB"}
|
package/dist/upload.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { open } from "node:fs/promises";
|
|
3
3
|
import { basename } from "node:path";
|
|
4
|
-
import { isTransientStatus } from "@manta-eu/
|
|
4
|
+
import { isTransientStatus } from "@manta-eu/auth";
|
|
5
5
|
// Must equal agents' uploadPartSize: the API presigns one part per this many bytes.
|
|
6
6
|
export const uploadPartSize = 8 * 1024 * 1024;
|
|
7
7
|
const initiateMutation = `mutation Initiate($input: InitiateUploadInput!) {
|
|
@@ -51,14 +51,14 @@ async function putPartOnce(url, body, fetchImpl) {
|
|
|
51
51
|
if (!response.ok) {
|
|
52
52
|
// The storage error document is the only thing that says why a part was refused.
|
|
53
53
|
const detail = (await response.text().catch(() => "")).slice(0, 500);
|
|
54
|
-
const message = `
|
|
54
|
+
const message = `storage refused a part with status ${response.status}: ${detail}`;
|
|
55
55
|
throw isTransientStatus(response.status) ? new Error(message) : new NonRetryablePartError(message);
|
|
56
56
|
}
|
|
57
57
|
// The part is already stored, so a body that cannot be discarded must not cost a re-PUT.
|
|
58
58
|
await response.body?.cancel().catch(() => undefined);
|
|
59
59
|
// The part is stored, so repeating the PUT cannot conjure the header completeUpload needs.
|
|
60
60
|
if (!etag) {
|
|
61
|
-
throw new NonRetryablePartError("
|
|
61
|
+
throw new NonRetryablePartError("storage stored a part but answered without an ETag");
|
|
62
62
|
}
|
|
63
63
|
return etag;
|
|
64
64
|
}
|
|
@@ -77,12 +77,12 @@ async function uploadHandle(file, options) {
|
|
|
77
77
|
const { path, fileName, graphql, fetchImpl = fetch, sleep = sleepFor } = options;
|
|
78
78
|
const byteSize = (await file.stat()).size;
|
|
79
79
|
if (byteSize === 0)
|
|
80
|
-
throw new Error(
|
|
80
|
+
throw new Error(`${path} is empty`);
|
|
81
81
|
const { initiateUpload } = await graphql(initiateMutation, { input: { fileName, byteSize } });
|
|
82
82
|
const { uploadId, storageKey, partUrls } = initiateUpload;
|
|
83
83
|
try {
|
|
84
84
|
if (partUrls.length !== Math.ceil(byteSize / uploadPartSize)) {
|
|
85
|
-
throw new Error(`
|
|
85
|
+
throw new Error(`the API split ${fileName} into ${partUrls.length} parts, which this client cannot fill`);
|
|
86
86
|
}
|
|
87
87
|
const parts = [];
|
|
88
88
|
// Hashed from the bytes that went up, so the hash describes the stored object.
|
|
@@ -91,7 +91,7 @@ async function uploadHandle(file, options) {
|
|
|
91
91
|
const start = (partNumber - 1) * uploadPartSize;
|
|
92
92
|
const body = await readFully(file, start, uploadPartSize);
|
|
93
93
|
if (body.length !== Math.min(uploadPartSize, byteSize - start)) {
|
|
94
|
-
throw new Error(
|
|
94
|
+
throw new Error(`${fileName} changed while it was being read`);
|
|
95
95
|
}
|
|
96
96
|
hash.update(body);
|
|
97
97
|
parts.push({ partNumber, etag: await putPart(url, body, fetchImpl, sleep) });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manta-eu/upload",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "CLI that uploads files to a Manta datastore, where supported types are ingested into its knowledge graph",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"commander": "^15.0.0",
|
|
19
|
-
"@manta-eu/
|
|
19
|
+
"@manta-eu/auth": "0.1.4"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^24.13.3",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"@typescript-eslint/parser": "^8.56.1",
|
|
25
25
|
"eslint": "^9.39.2",
|
|
26
26
|
"rimraf": "^6.1.3",
|
|
27
|
-
"typescript": "^5.
|
|
28
|
-
"vitest": "^
|
|
27
|
+
"typescript": "^5.9.3",
|
|
28
|
+
"vitest": "^4.1.8"
|
|
29
29
|
},
|
|
30
30
|
"main": "./dist/index.js",
|
|
31
31
|
"types": "./dist/index.d.ts",
|