@yargote/artifacts-cli 0.1.0-beta.7 → 0.1.0-beta.9
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 +47 -15
- package/package.json +3 -3
- package/skills/artifacts-cli/SKILL.md +3 -1
- package/skills/artifacts-cli/references/commands.md +23 -6
- package/src/auth.d.ts +4 -1
- package/src/auth.js +184 -45
- package/src/credentials.d.ts +2 -0
- package/src/credentials.js +22 -0
- package/src/index.js +67 -20
- package/src/operations.d.ts +3 -1
package/README.md
CHANGED
|
@@ -11,9 +11,9 @@ Use Node 24 or later. The beta targets macOS and Linux. Windows has not been ver
|
|
|
11
11
|
Choose one persistent installation:
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
npm install --global @yargote/artifacts-cli@0.1.0-beta.
|
|
14
|
+
npm install --global @yargote/artifacts-cli@0.1.0-beta.9
|
|
15
15
|
# Or, with pnpm and an existing global bin directory on PATH:
|
|
16
|
-
pnpm add --global @yargote/artifacts-cli@0.1.0-beta.
|
|
16
|
+
pnpm add --global @yargote/artifacts-cli@0.1.0-beta.9
|
|
17
17
|
artifacts --version
|
|
18
18
|
```
|
|
19
19
|
|
|
@@ -22,23 +22,26 @@ If pnpm reports that no global bin directory exists, run `pnpm setup` and reopen
|
|
|
22
22
|
For one-off execution, pin the same version:
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
npx --yes @yargote/artifacts-cli@0.1.0-beta.
|
|
26
|
-
pnpm dlx @yargote/artifacts-cli@0.1.0-beta.
|
|
25
|
+
npx --yes @yargote/artifacts-cli@0.1.0-beta.9 --help
|
|
26
|
+
pnpm dlx @yargote/artifacts-cli@0.1.0-beta.9 --help
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
`@beta` follows future prereleases. Exact versions remain reproducible. No stable `latest` release is part of this beta. An organization's pnpm minimum release age or trust policy can delay a new release. Follow that policy rather than disabling it globally.
|
|
30
30
|
|
|
31
31
|
## Complete first use
|
|
32
32
|
|
|
33
|
+
Check `artifacts --help` for saved-connection support. With an older package that requires a server on every command, keep `ARTIFACTS_SERVER` set to the selected REST resource.
|
|
34
|
+
|
|
33
35
|
An existing Artifacts account is required. Select the REST OAuth resource, which is the API origin without `/mcp`. When upgrading an older deployment, complete the [API migration](../../docs/deployment.md#migrate-the-api-hostname) before using the address below.
|
|
34
36
|
|
|
35
37
|
```sh
|
|
36
|
-
|
|
37
|
-
artifacts login
|
|
38
|
+
artifacts login --server 'https://artifacts-api.yersonargotev.workers.dev'
|
|
38
39
|
artifacts whoami
|
|
39
40
|
```
|
|
40
41
|
|
|
41
|
-
Login opens browser authorization. After you approve access
|
|
42
|
+
Login opens browser authorization. After you approve access and the CLI verifies your identity, it saves the resolved REST resource in private state. A new terminal can run `artifacts whoami` without a server option or environment variable. It prints JSON with your user ID and scopes.
|
|
43
|
+
|
|
44
|
+
Use `artifacts login --no-browser` to open the printed authorization URL yourself on the same machine. This manual flow saves the connection only after identity verification succeeds.
|
|
42
45
|
|
|
43
46
|
Create `snapshot.json`:
|
|
44
47
|
|
|
@@ -82,21 +85,33 @@ Run `artifacts logout` before uninstalling if you want to clear credentials. Uni
|
|
|
82
85
|
|
|
83
86
|
Skills, help, and version commands work without a server or login. Skills commands do not read credentials or create private state. They accept and ignore global `--server` and `--state-dir` values, including their environment defaults.
|
|
84
87
|
|
|
85
|
-
`--server RESOURCE`
|
|
88
|
+
`--server RESOURCE` overrides `ARTIFACTS_SERVER`, which overrides the connection saved by the last successful login in the selected state directory.
|
|
89
|
+
|
|
90
|
+
An ordinary command override applies to that command and does not replace the saved connection. A later successful login replaces it with the verified canonical REST resource. A failed login to another server preserves the previous selection and its credentials.
|
|
91
|
+
|
|
92
|
+
Login also accepts an MCP resource that advertises its REST resource. Use the configured `API_URL` origin to call REST under `/v1`.
|
|
93
|
+
|
|
94
|
+
Without an override or saved connection, run `artifacts login --server RESOURCE` first. The CLI has no production default and does not rewrite the supplied URL. Existing installations keep their exact-resource credentials, but must complete a successful login to save a connection.
|
|
95
|
+
|
|
96
|
+
Older deployments without `API_URL` still use their complete MCP resource and routes under `/mcp/v1`.
|
|
86
97
|
|
|
87
98
|
A hostname change requires a new `artifacts login --server RESOURCE`. Credentials and retained operations remain bound to their exact original resource. Do not move an old operation file to the new resource or redirect its bearer requests.
|
|
88
99
|
|
|
89
|
-
`--state-dir DIR` overrides `ARTIFACTS_STATE_DIR`. The default is `~/.local/state/artifacts`. The state directory must be private and owned by the current user. Credentials
|
|
100
|
+
`--state-dir DIR` overrides `ARTIFACTS_STATE_DIR`. The default is `~/.local/state/artifacts`. The state directory must be private and owned by the current user. Each state directory has its own saved connection and credential profiles. Credentials and the saved connection share one private database, separate from operation files. Profiles bind credentials to the exact resource. Retained operation paths hash the resource and request ID, so request IDs cannot select filesystem paths.
|
|
90
101
|
|
|
91
102
|
HTTPS is required except for HTTP on the exact loopback hosts `localhost`, `127.0.0.1`, and `[::1]`. Server URLs cannot contain credentials, a query, or a fragment.
|
|
92
103
|
|
|
93
104
|
## Authentication
|
|
94
105
|
|
|
95
|
-
`artifacts login --server RESOURCE` verifies protected-resource metadata
|
|
106
|
+
`artifacts login --server RESOURCE` verifies protected-resource metadata. If a resource advertises `artifacts_rest_resource`, the relation may name the same resource or one distinct REST resource. Login verifies metadata for a distinct resource and prints its REST URL to stderr. The distinct resource may omit the relation or name itself, but cannot name another resource. After identity verification, login saves that REST URL for later commands. An existing `ARTIFACTS_SERVER` still overrides it. Unset the variable to use the saved connection. Login anonymously checks `<resource>/v1/identity` before registering a public native OAuth client and opening browser authorization with PKCE. This check confirms REST compatibility. Login verifies your identity again with the received token before atomically saving credentials and the connection.
|
|
107
|
+
|
|
108
|
+
Errors identify the phase, method, endpoint, and HTTP status when a response exists. Network failures have no HTTP status. If the compatibility check fails, confirm the configured REST URL with your service administrator. A valid MCP metadata document alone does not establish REST compatibility.
|
|
109
|
+
|
|
110
|
+
`--no-browser` runs the same checks and prints the authorization URL for manual navigation. The loopback callback listener starts before browser navigation. Login requests read, write, and publication scopes. Client registration is reused for the profile.
|
|
96
111
|
|
|
97
112
|
`artifacts whoami` returns the verified user, session, OAuth client, and scopes.
|
|
98
113
|
|
|
99
|
-
`artifacts logout` clears local credentials and attempts to revoke the refresh token. Its JSON contains `signedOut: true` and `remoteRevocation` with `confirmed`, `unconfirmed`, or `not-needed`. Issued access JWTs can remain valid until expiry or central-session revocation.
|
|
114
|
+
`artifacts logout` clears local credentials and attempts to revoke the refresh token. It preserves the saved connection, so `artifacts login` can reuse the server without another URL. Its JSON contains `signedOut: true` and `remoteRevocation` with `confirmed`, `unconfirmed`, or `not-needed`. Issued access JWTs can remain valid until expiry or central-session revocation.
|
|
100
115
|
|
|
101
116
|
`ARTIFACTS_ACCESS_TOKEN` supplies an access token for automation. It does not require local login. Tokens are never command arguments or retained operation fields.
|
|
102
117
|
|
|
@@ -110,12 +125,14 @@ The private `credentials.sqlite` file stores credentials. The separate `credenti
|
|
|
110
125
|
artifacts login [--no-browser]
|
|
111
126
|
artifacts logout
|
|
112
127
|
artifacts whoami
|
|
113
|
-
artifacts create --snapshot FILE [--project NAME] [--request-id ID]
|
|
128
|
+
artifacts create --snapshot FILE [--project NAME] [--slug SLUG] [--request-id ID]
|
|
114
129
|
[--response full|compact]
|
|
115
130
|
artifacts create --command FILE
|
|
116
131
|
artifacts list [--limit N] [--cursor CURSOR] [--project NAME]
|
|
117
132
|
artifacts projects
|
|
118
133
|
artifacts read ID [--revision REV] [--response full|compact] [--out FILE [--quiet]]
|
|
134
|
+
artifacts read --slug SLUG [--project NAME] [--revision REV] [--response full|compact]
|
|
135
|
+
[--out FILE [--quiet]]
|
|
119
136
|
artifacts update ID --snapshot FILE --expected-revision REV [--request-id ID]
|
|
120
137
|
[--response full|compact]
|
|
121
138
|
artifacts update [ID] --command FILE
|
|
@@ -124,8 +141,7 @@ artifacts history ID [--limit N] [--before-sequence N] [--summary]
|
|
|
124
141
|
artifacts publish ID --revision REV --expected-version N [--request-id ID]
|
|
125
142
|
artifacts publish [ID] --command FILE
|
|
126
143
|
artifacts export ID --revision REV --out FILE
|
|
127
|
-
artifacts move ID --project NAME [--request-id ID]
|
|
128
|
-
artifacts move ID --no-project [--request-id ID]
|
|
144
|
+
artifacts move ID [--project NAME | --no-project] [--slug SLUG | --no-slug] [--request-id ID]
|
|
129
145
|
artifacts share ID --email EMAIL [--notify] [--request-id ID]
|
|
130
146
|
artifacts shares ID
|
|
131
147
|
artifacts unshare ID SHARE_ID [--request-id ID]
|
|
@@ -198,10 +214,26 @@ artifacts move ARTIFACT_ID --no-project
|
|
|
198
214
|
|
|
199
215
|
Every artifact read and list item includes `project`, a name or null. `projects` prints `{ "items": [{ "name": "Alpha", "artifactCount": 1 }] }`, ordered by name without regard to letter case. A project exists only while it contains an artifact.
|
|
200
216
|
|
|
201
|
-
`move ID` requires
|
|
217
|
+
`move ID` requires at least one of `--project NAME`, `--no-project`, `--slug SLUG`, or `--no-slug`, and accepts at most one from each pair. An option left out keeps that value. It prints `{ "artifactId": "...", "project": "Beta" }` with the stored spelling, or `null` after `--no-project`. Move never creates a revision or changes the publication. Moving to the current project succeeds without change. Move is a retained mutation like the others, so `retry` recovers a lost response.
|
|
202
218
|
|
|
203
219
|
Version `0.1.0-beta.4` and earlier parse artifact responses strictly and fail with `invalid_response` against a server that returns `project`. Upgrade the CLI together with the server.
|
|
204
220
|
|
|
221
|
+
## Slugs
|
|
222
|
+
|
|
223
|
+
A slug names a page within its project, so `read` can find it without its ID. `create --slug SLUG` sets it. A slug contains 1 to 160 characters of lowercase ASCII kebab-case segments separated by `/`, such as `concepts/rag` or `index`. It is never normalized, and a trailing `.md` is rejected with a hint to remove it. A complete create command file can include the same optional `slug` field.
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
artifacts create --snapshot snapshot.json --project Mi-Wiki --slug concepts/rag
|
|
227
|
+
artifacts read --project mi-wiki --slug concepts/rag
|
|
228
|
+
artifacts read --slug index
|
|
229
|
+
artifacts move ARTIFACT_ID --slug concepts/retrieval
|
|
230
|
+
artifacts move ARTIFACT_ID --no-slug
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
A slug is unique within the owner's project, matched by the project's stored spelling. Artifacts without a project share one unfiled namespace, which `read --slug` reads without `--project`. `read` takes exactly one of an ID or `--slug`, and `--project` applies only with `--slug`. A used slug fails `create` with `slug_conflict`, status 409, and the holder's `artifactId`. A locator with no artifact fails with `not_found`. Every artifact read and list item includes `slug`, a name or null. Version `0.1.0-beta.7` and earlier reject responses that include `slug` with `invalid_response`.
|
|
234
|
+
|
|
235
|
+
`move ID --slug SLUG` renames a page in its project, and `move ID --no-slug` clears the slug. A move to another project keeps the slug. A move fails with `slug_conflict`, status 409, and the holder's `artifactId` when the target project already uses the target slug, and then changes nothing. A slug change creates no revision. Verify the new slug with `read`. The move result is unchanged, so published CLI versions keep working against this server.
|
|
236
|
+
|
|
205
237
|
## Share publication access
|
|
206
238
|
|
|
207
239
|
These commands are available from `0.1.0-beta.6`. Check installed help for support.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yargote/artifacts-cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.9",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Command-line client for the Artifacts REST API.",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"skills"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@yargote/artifacts-contracts": "0.1.0-beta.
|
|
31
|
-
"@yargote/artifacts-client": "0.1.0-beta.
|
|
30
|
+
"@yargote/artifacts-contracts": "0.1.0-beta.9",
|
|
31
|
+
"@yargote/artifacts-client": "0.1.0-beta.9",
|
|
32
32
|
"openid-client": "6.8.8",
|
|
33
33
|
"zod": "4.6.5"
|
|
34
34
|
},
|
|
@@ -9,7 +9,7 @@ Use the installed `artifacts` executable. Run `artifacts --help` and read [the c
|
|
|
9
9
|
|
|
10
10
|
## Select the account and resource
|
|
11
11
|
|
|
12
|
-
Use the resource selected by the user
|
|
12
|
+
Use the resource selected by the user. Service commands select `--server RESOURCE` before `ARTIFACTS_SERVER`, then the connection saved by a successful login in the selected private state directory. Ordinary command overrides do not replace that connection. Skills, help, and version commands work offline without a server or login. Check installed help for saved-connection support. A resource is the canonical OAuth URL, such as `https://artifacts.example.test/mcp`. REST routes are below that resource at `/v1`. Do not infer a production connection from an example or silently switch to a local server.
|
|
13
13
|
|
|
14
14
|
Run `artifacts whoami` before the first mutation when the current identity is not established. Keep the selected resource and owner consistent throughout one operation. If login is needed, use `artifacts login --server RESOURCE`. The user authenticates in the browser. Do not collect their password or read another application's credentials. Never put tokens in command arguments, operation files, logs, or the final reply.
|
|
15
15
|
|
|
@@ -35,6 +35,8 @@ Use `edit` for partial changes that preserve omitted fields. Use `update` for a
|
|
|
35
35
|
|
|
36
36
|
When the user names a project for new content, add `--project NAME` to `create`. Run `artifacts projects` first when an existing project may already fit. A name matching an existing project regardless of letter case joins it and keeps the stored spelling. Do not invent a project the user did not ask for. Use `list --project NAME` to find artifacts in a project.
|
|
37
37
|
|
|
38
|
+
When the user names a page, add `--slug SLUG` such as `concepts/rag` to `create`, and read it later with `read --project NAME --slug SLUG`, or `read --slug SLUG` for an unfiled page, instead of listing for its ID. Omit the `.md` suffix. A `slug_conflict` error names the `artifactId` that already holds the slug in that project. Rename a page with `move ID --slug SLUG` and clear its slug with `move ID --no-slug`. A move to another project keeps the slug, and a move into a used slug fails with `slug_conflict` without changing anything.
|
|
39
|
+
|
|
38
40
|
Use `move ID --project NAME` to file an existing artifact under a project and `move ID --no-project` to remove it from one. A move changes only the project. It creates no revision and leaves the publication unchanged. It is a retained mutation, so recover an uncertain move with `retry`. Verify it with `read` or `list --project NAME`. A project disappears when its last artifact leaves it.
|
|
39
41
|
|
|
40
42
|
## Publish deliberately
|
|
@@ -24,16 +24,17 @@ The published `0.1.0-beta.1` package predates skills commands. Do not assume sup
|
|
|
24
24
|
## Authenticate
|
|
25
25
|
|
|
26
26
|
```sh
|
|
27
|
-
|
|
28
|
-
artifacts login
|
|
27
|
+
artifacts login --server https://artifacts.example.test/mcp
|
|
29
28
|
artifacts whoami
|
|
30
29
|
```
|
|
31
30
|
|
|
32
|
-
Replace the example with the selected resource before executing it. Service commands
|
|
31
|
+
Replace the example with the selected resource before executing it. Service commands select `--server RESOURCE` before `ARTIFACTS_SERVER`, then the connection saved by a successful identity-verified login. Ordinary overrides do not replace the saved connection. A later successful login replaces it with the verified REST resource. A failed login to another server preserves the previous connection and its credentials. Check installed help for saved-connection support.
|
|
32
|
+
|
|
33
|
+
`--state-dir DIR` or `ARTIFACTS_STATE_DIR` selects an isolated private state directory. For automation, `ARTIFACTS_ACCESS_TOKEN` supplies an already authorized bearer token. Do not echo it or put it in an argument.
|
|
33
34
|
|
|
34
35
|
Use `--no-browser` when the environment cannot open a browser automatically. Open the printed authorization URL and keep the CLI running for its loopback callback. A remote machine's loopback callback must be reachable by the browser used for login. Do not replace the callback with an arbitrary public listener.
|
|
35
36
|
|
|
36
|
-
`logout` clears local credentials and attempts refresh-token revocation. Copied access JWTs can remain valid until expiry. The existing account page's global session revocation stops access across CLI, REST, MCP, and panel. Local logout does not claim to perform that global action.
|
|
37
|
+
`logout` clears local credentials and attempts refresh-token revocation. It keeps the saved connection, so a later `artifacts login` can reuse it. Copied access JWTs can remain valid until expiry. The existing account page's global session revocation stops access across CLI, REST, MCP, and panel. Local logout does not claim to perform that global action.
|
|
37
38
|
|
|
38
39
|
## Create a complete snapshot
|
|
39
40
|
|
|
@@ -103,7 +104,23 @@ artifacts move ARTIFACT_ID --no-project
|
|
|
103
104
|
|
|
104
105
|
A project name contains 1 to 80 characters after trimming and NFC normalization, without control characters. A name that matches an existing project regardless of letter case joins it and keeps its stored spelling. Every read and list item includes `project`, a name or null. `projects` prints `{ "items": [{ "name": "Client onboarding", "artifactCount": 1 }] }`, ordered by name without regard to letter case. A project exists only while it contains an artifact.
|
|
105
106
|
|
|
106
|
-
`move` takes
|
|
107
|
+
`move` takes at least one of `--project NAME`, `--no-project`, `--slug SLUG`, or `--no-slug`, and at most one from each pair. It prints `{ "artifactId": "...", "project": "Archive" }`, or `null` for the project after `--no-project`. An option left out keeps that value. It creates no revision and leaves the publication unchanged. Moving to the current project succeeds without change. A lost move response is recovered with `retry REQUEST_ID` like any other retained mutation.
|
|
108
|
+
|
|
109
|
+
## Name pages with slugs
|
|
110
|
+
|
|
111
|
+
Check that installed help lists `--slug` before using it.
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
artifacts create --snapshot snapshot.json --project "Mi wiki" --slug concepts/rag
|
|
115
|
+
artifacts read --project "mi wiki" --slug concepts/rag
|
|
116
|
+
artifacts read --slug index
|
|
117
|
+
artifacts move ARTIFACT_ID --slug concepts/retrieval
|
|
118
|
+
artifacts move ARTIFACT_ID --no-slug
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
A slug contains 1 to 160 characters of lowercase ASCII kebab-case segments separated by `/`, such as `concepts/rag` or `index`. It is never normalized, and a trailing `.md` is rejected with a hint to remove it. A slug is unique within the owner's project, matched by the project's stored spelling. Artifacts without a project share one unfiled namespace, which `read --slug` without `--project` reads. Every read and list item includes `slug`, a name or null.
|
|
122
|
+
|
|
123
|
+
`read` takes exactly one of an ID or `--slug`. `--project` applies only with `--slug`. A used slug fails `create` with `slug_conflict`, status 409, and the holder's `artifactId` in `details`. A locator with no artifact fails with `not_found`. `move ID --slug SLUG` renames a page in its project, and `move ID --no-slug` clears the slug. A move to another project keeps the slug unless `--slug` or `--no-slug` changes it. A move fails with `slug_conflict` and changes nothing when the target project already uses the target slug. A slug change creates no revision.
|
|
107
124
|
|
|
108
125
|
## Edit without losing retained data
|
|
109
126
|
|
|
@@ -134,7 +151,7 @@ Command files contain the command itself, without the CLI's retained-operation w
|
|
|
134
151
|
| `update` | `requestId`, `artifactId`, `expectedRevisionId`, `snapshot` |
|
|
135
152
|
| `publish` | `requestId`, `artifactId`, `revisionId`, `expectedPublicationVersion` |
|
|
136
153
|
|
|
137
|
-
`requestId` is a caller-chosen string of 8 to 128 characters. Create and update also accept optional `response` with value `full` or `compact`. Create also accepts an optional `project` name
|
|
154
|
+
`requestId` is a caller-chosen string of 8 to 128 characters. Create and update also accept optional `response` with value `full` or `compact`. Create also accepts an optional `project` name and an optional `slug`. This complete update example replaces a snapshot. Substitute the observed IDs and preserve all required content and attachments in its `snapshot`.
|
|
138
155
|
|
|
139
156
|
```json
|
|
140
157
|
{
|
package/src/auth.d.ts
CHANGED
|
@@ -4,7 +4,10 @@ export declare function login(options: {
|
|
|
4
4
|
stateDir: string;
|
|
5
5
|
noBrowser: boolean;
|
|
6
6
|
onAuthorizationUrl: (url: string) => void;
|
|
7
|
-
}): Promise<
|
|
7
|
+
}): Promise<{
|
|
8
|
+
resource: string;
|
|
9
|
+
identity: Identity;
|
|
10
|
+
}>;
|
|
8
11
|
export declare function accessToken(options: {
|
|
9
12
|
resource: string;
|
|
10
13
|
stateDir: string;
|
package/src/auth.js
CHANGED
|
@@ -2,13 +2,12 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
4
|
import * as oidc from 'openid-client';
|
|
5
|
-
import { canonicalResource, identitySchema, protectedResourceMetadataUrl, restBase } from '@yargote/artifacts-contracts/http';
|
|
6
|
-
import {
|
|
5
|
+
import { canonicalResource, httpErrorSchema, identitySchema, protectedResourceMetadataSchema, protectedResourceMetadataUrl, restBase } from '@yargote/artifacts-contracts/http';
|
|
6
|
+
import { HttpError } from '@yargote/artifacts-client';
|
|
7
7
|
import { CredentialStore } from "./credentials.js";
|
|
8
8
|
const networkTimeout = 10_000;
|
|
9
9
|
const callbackTimeout = 180_000;
|
|
10
10
|
const scopes = 'openid profile offline_access artifacts:read artifacts:write artifacts:publish';
|
|
11
|
-
const resourceMetadata = z.object({ resource: z.string(), authorization_servers: z.array(z.string()).min(1) });
|
|
12
11
|
function endpoint(value, issuer) {
|
|
13
12
|
const url = new URL(value);
|
|
14
13
|
const loopback = /^http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost)(?::[0-9]+)?(?:\/|$)/.test(value);
|
|
@@ -18,12 +17,34 @@ function endpoint(value, issuer) {
|
|
|
18
17
|
throw new Error('OAuth endpoints must use the issuer origin');
|
|
19
18
|
return url;
|
|
20
19
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
class AuthTransportError extends Error {
|
|
21
|
+
}
|
|
22
|
+
function safeUrl(value) {
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(value);
|
|
25
|
+
return `${url.origin}${url.pathname}`;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return '[invalid endpoint]';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function authFailure(context, code, explanation, status) {
|
|
32
|
+
const message = `${context.phase} ${context.method} ${safeUrl(context.url)}${status === undefined ? '' : ` HTTP ${status}`}: ${explanation}`;
|
|
33
|
+
return status === undefined ? new AuthTransportError(message) : new HttpError(status, code, message);
|
|
34
|
+
}
|
|
35
|
+
async function boundedFetch(context, options = {}) {
|
|
36
|
+
const timeout = AbortSignal.timeout(networkTimeout);
|
|
37
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
38
|
+
let response;
|
|
39
|
+
try {
|
|
40
|
+
response = await fetch(context.url, { ...options, redirect: 'manual', signal });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw authFailure(context, 'network_error', timeout.aborted || (options.signal?.aborted && options.signal.reason instanceof DOMException && options.signal.reason.name === 'TimeoutError') ? 'Request timed out' : options.signal?.aborted ? 'Request cancelled' : 'Network request failed');
|
|
44
|
+
}
|
|
24
45
|
if (response.status >= 300 && response.status < 400) {
|
|
25
|
-
await response.body?.cancel();
|
|
26
|
-
throw
|
|
46
|
+
await response.body?.cancel().catch(() => { });
|
|
47
|
+
throw authFailure(context, 'redirect_refused', 'Redirect refused', response.status);
|
|
27
48
|
}
|
|
28
49
|
const reader = response.body?.getReader();
|
|
29
50
|
const chunks = [];
|
|
@@ -36,13 +57,13 @@ async function boundedFetch(url, options = {}) {
|
|
|
36
57
|
break;
|
|
37
58
|
size += chunk.value.byteLength;
|
|
38
59
|
if (size > 1024 * 1024)
|
|
39
|
-
throw new Error('
|
|
60
|
+
throw new Error('too large');
|
|
40
61
|
chunks.push(chunk.value);
|
|
41
62
|
}
|
|
42
63
|
}
|
|
43
|
-
catch
|
|
64
|
+
catch {
|
|
44
65
|
await reader.cancel().catch(() => { });
|
|
45
|
-
throw
|
|
66
|
+
throw authFailure(context, 'invalid_response', timeout.aborted || (options.signal?.aborted && options.signal.reason instanceof DOMException && options.signal.reason.name === 'TimeoutError') ? 'Response timed out' : options.signal?.aborted ? 'Request cancelled' : 'Cannot read response', response.status);
|
|
46
67
|
}
|
|
47
68
|
finally {
|
|
48
69
|
reader.releaseLock();
|
|
@@ -50,31 +71,155 @@ async function boundedFetch(url, options = {}) {
|
|
|
50
71
|
}
|
|
51
72
|
return new Response(size ? Buffer.concat(chunks) : null, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
52
73
|
}
|
|
53
|
-
function
|
|
74
|
+
function jsonResponse(response, context) {
|
|
75
|
+
if (response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() !== 'application/json')
|
|
76
|
+
throw authFailure(context, 'invalid_response', 'Expected a JSON response', response.status);
|
|
77
|
+
return response.json().catch(() => { throw authFailure(context, 'invalid_response', 'Invalid JSON response', response.status); });
|
|
78
|
+
}
|
|
79
|
+
async function discoverResource(resource, terminal = false) {
|
|
80
|
+
const context = { phase: 'resource_metadata', method: 'GET', url: protectedResourceMetadataUrl(resource) };
|
|
81
|
+
const response = await boundedFetch(context);
|
|
82
|
+
if (!response.ok)
|
|
83
|
+
throw authFailure(context, response.status >= 500 ? 'service_error' : 'incompatible_resource', response.status >= 500 ? 'Resource metadata service failed' : 'Cannot discover the protected resource. Confirm the configured REST URL', response.status);
|
|
84
|
+
const document = await jsonResponse(response, context);
|
|
85
|
+
const parsed = protectedResourceMetadataSchema.safeParse(document);
|
|
86
|
+
if (!parsed.success)
|
|
87
|
+
throw authFailure(context, 'invalid_response', 'Invalid protected resource metadata', response.status);
|
|
88
|
+
if (parsed.data.resource !== resource)
|
|
89
|
+
throw authFailure(context, 'invalid_response', 'Protected resource metadata does not match the exact server URL', response.status);
|
|
90
|
+
if (terminal && parsed.data.artifacts_rest_resource !== undefined && parsed.data.artifacts_rest_resource !== resource)
|
|
91
|
+
throw authFailure(context, 'invalid_response', 'REST resource metadata must not advertise another artifacts_rest_resource', response.status);
|
|
92
|
+
const issuer = parsed.data.authorization_servers[0];
|
|
93
|
+
try {
|
|
94
|
+
endpoint(issuer);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw authFailure(context, 'invalid_response', 'Invalid authorization server URL in protected resource metadata', response.status);
|
|
98
|
+
}
|
|
99
|
+
return { issuer, ...(parsed.data.artifacts_rest_resource === undefined ? {} : { restResource: parsed.data.artifacts_rest_resource }) };
|
|
100
|
+
}
|
|
101
|
+
function compatibleChallenge(value) {
|
|
102
|
+
if (!value)
|
|
103
|
+
return false;
|
|
104
|
+
const match = /^Bearer\s+(.+)$/i.exec(value);
|
|
105
|
+
if (!match)
|
|
106
|
+
return false;
|
|
107
|
+
const parameters = new Map();
|
|
108
|
+
const input = match[1];
|
|
109
|
+
const parameter = /\s*([!#$%&'*+.^_`|~0-9A-Za-z-]+)\s*=\s*(?:"((?:[^"\\]|\\[\x20-\x7e])*)"|([!#$%&'*+.^_`|~0-9A-Za-z-]+))\s*(,|$)/gy;
|
|
110
|
+
let position = 0;
|
|
111
|
+
while (position < input.length) {
|
|
112
|
+
parameter.lastIndex = position;
|
|
113
|
+
const param = parameter.exec(input);
|
|
114
|
+
if (!param || (param[4] === ',' && parameter.lastIndex === input.length))
|
|
115
|
+
return false;
|
|
116
|
+
const key = param[1].toLowerCase();
|
|
117
|
+
if (parameters.has(key))
|
|
118
|
+
return false;
|
|
119
|
+
parameters.set(key, param[2] === undefined ? param[3] : param[2].replace(/\\([\x20-\x7e])/g, '$1'));
|
|
120
|
+
position = parameter.lastIndex;
|
|
121
|
+
}
|
|
122
|
+
return parameters.get('error') === 'invalid_token' && (parameters.get('scope')?.split(/\s+/).includes('artifacts:read') ?? false);
|
|
123
|
+
}
|
|
124
|
+
async function checkIdentity(resource, request) {
|
|
125
|
+
const context = { phase: request.kind === 'anonymous' ? 'rest_preflight' : 'authenticated_identity', method: 'GET', url: `${restBase(resource)}/identity` };
|
|
126
|
+
const response = await boundedFetch(context, request.kind === 'anonymous' ? {} : { signal: request.signal, headers: { authorization: `Bearer ${request.accessToken}` } });
|
|
127
|
+
if (request.kind === 'anonymous') {
|
|
128
|
+
if (response.status === 401) {
|
|
129
|
+
const body = httpErrorSchema.safeParse(await jsonResponse(response, context));
|
|
130
|
+
if (body.success && body.data.error.code === 'unauthenticated' && compatibleChallenge(response.headers.get('www-authenticate')))
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
throw authFailure(context, response.status >= 500 ? 'service_error' : 'incompatible_resource', response.status >= 500 ? 'REST identity service failed' : 'REST identity route is incompatible. Use the exact REST URL or configure artifacts_rest_resource on the MCP server', response.status);
|
|
134
|
+
}
|
|
135
|
+
if (!response.ok)
|
|
136
|
+
throw authFailure(context, response.status >= 500 ? 'service_error' : response.status === 401 || response.status === 403 ? 'access_denied' : 'incompatible_resource', response.status >= 500 ? 'REST identity service failed' : response.status === 401 || response.status === 403 ? 'OAuth grant cannot access this Artifacts server' : 'REST identity route is unavailable. Confirm the configured REST URL', response.status);
|
|
137
|
+
const parsed = identitySchema.safeParse(await jsonResponse(response, context));
|
|
138
|
+
if (!parsed.success)
|
|
139
|
+
throw authFailure(context, 'invalid_response', 'Invalid REST identity response', response.status);
|
|
140
|
+
if (request.claims.sub !== parsed.data.userId || (request.claims.sid !== undefined && request.claims.sid !== parsed.data.sessionId))
|
|
141
|
+
throw authFailure(context, 'invalid_response', 'OAuth identity does not match the Artifacts session', response.status);
|
|
142
|
+
return parsed.data;
|
|
143
|
+
}
|
|
144
|
+
async function oauth(issuer, signal, operation) {
|
|
145
|
+
const observations = [];
|
|
146
|
+
try {
|
|
147
|
+
const result = await operation(discoveryOptions(issuer, signal, observations));
|
|
148
|
+
if (result instanceof oidc.Configuration) {
|
|
149
|
+
validateConfiguration(result, issuer, observations);
|
|
150
|
+
result[oidc.customFetch] = discoveryOptions(issuer, signal)[oidc.customFetch];
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
let cause = error;
|
|
156
|
+
let observed;
|
|
157
|
+
for (let depth = 0; depth < 5; depth++) {
|
|
158
|
+
if (cause instanceof HttpError || cause instanceof AuthTransportError)
|
|
159
|
+
throw cause;
|
|
160
|
+
const response = cause instanceof Response ? cause : cause instanceof Error && 'response' in cause ? cause.response : undefined;
|
|
161
|
+
if (response instanceof Response)
|
|
162
|
+
observed = observations.find((item) => item.response === response);
|
|
163
|
+
if (observed || !(cause instanceof Error))
|
|
164
|
+
break;
|
|
165
|
+
cause = cause.cause;
|
|
166
|
+
}
|
|
167
|
+
observed ??= observations.at(-1);
|
|
168
|
+
if (observed)
|
|
169
|
+
throw authFailure(observed.context, 'invalid_response', 'OAuth protocol validation failed', observed.response.status);
|
|
170
|
+
throw new Error('OAuth protocol validation failed');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function discoveryOptions(issuer, signal, observations) {
|
|
54
174
|
endpoint(issuer);
|
|
55
175
|
return {
|
|
56
176
|
timeout: networkTimeout / 1000,
|
|
57
177
|
execute: [...(new URL(issuer).protocol === 'http:' ? [oidc.allowInsecureRequests] : []), oidc.enableNonRepudiationChecks],
|
|
58
|
-
[oidc.customFetch]: (url, options) =>
|
|
178
|
+
[oidc.customFetch]: async (url, options) => {
|
|
179
|
+
const context = { phase: 'oauth', method: options.method ?? 'GET', url };
|
|
180
|
+
try {
|
|
181
|
+
endpoint(url, issuer);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
throw authFailure({ ...context, url: issuer }, 'invalid_response', 'Invalid OAuth endpoint');
|
|
185
|
+
}
|
|
186
|
+
const response = await boundedFetch(context, { ...options, signal: signal && options.signal ? AbortSignal.any([signal, options.signal]) : signal ?? options.signal, body: options.body instanceof Uint8Array ? Uint8Array.from(options.body) : options.body });
|
|
187
|
+
if (!response.ok)
|
|
188
|
+
throw authFailure(context, response.status >= 500 ? 'service_error' : 'oauth_error', response.status >= 500 ? 'OAuth service failed' : 'OAuth request failed', response.status);
|
|
189
|
+
observations?.push({ context, response });
|
|
190
|
+
return response;
|
|
191
|
+
},
|
|
59
192
|
};
|
|
60
193
|
}
|
|
61
|
-
function validateConfiguration(config, issuer) {
|
|
194
|
+
function validateConfiguration(config, issuer, observations) {
|
|
195
|
+
const discovery = observations.find((item) => item.context.method === 'GET');
|
|
196
|
+
const registration = observations.find((item) => item.context.method === 'POST');
|
|
197
|
+
if (!discovery)
|
|
198
|
+
throw new Error('OAuth discovery response was not observed');
|
|
199
|
+
const failDiscovery = (message) => authFailure(discovery.context, 'invalid_response', message, discovery.response.status);
|
|
62
200
|
const metadata = config.serverMetadata();
|
|
63
201
|
if (metadata.issuer !== issuer)
|
|
64
|
-
throw
|
|
202
|
+
throw failDiscovery('OAuth issuer metadata does not match the configured issuer');
|
|
65
203
|
for (const [key, value] of Object.entries(metadata)) {
|
|
66
|
-
if ((key.endsWith('_endpoint') || key === 'jwks_uri') && typeof value === 'string')
|
|
67
|
-
|
|
204
|
+
if ((key.endsWith('_endpoint') || key === 'jwks_uri') && typeof value === 'string') {
|
|
205
|
+
try {
|
|
206
|
+
endpoint(value, issuer);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
const message = error instanceof Error && error.message === 'OAuth endpoints must use the issuer origin' ? error.message : 'OAuth URLs require HTTPS or an exact loopback HTTP host, without credentials, query, or fragment';
|
|
210
|
+
throw failDiscovery(message);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
68
213
|
}
|
|
69
214
|
if (!metadata.authorization_endpoint || !metadata.token_endpoint)
|
|
70
|
-
throw
|
|
71
|
-
if (config.clientMetadata().client_secret || config.clientMetadata().token_endpoint_auth_method !== 'none')
|
|
72
|
-
|
|
215
|
+
throw failDiscovery('OAuth metadata is missing authorization or token endpoints');
|
|
216
|
+
if (config.clientMetadata().client_secret || config.clientMetadata().token_endpoint_auth_method !== 'none') {
|
|
217
|
+
const message = 'The CLI requires a public OAuth client without a secret';
|
|
218
|
+
throw registration ? authFailure(registration.context, 'invalid_response', message, registration.response.status) : new Error(message);
|
|
219
|
+
}
|
|
73
220
|
}
|
|
74
221
|
async function configuration(registration, signal) {
|
|
75
|
-
|
|
76
|
-
validateConfiguration(config, registration.issuer);
|
|
77
|
-
return config;
|
|
222
|
+
return oauth(registration.issuer, signal, (options) => oidc.discovery(new URL(registration.issuer), registration.clientId, { token_endpoint_auth_method: 'none', id_token_signed_response_alg: 'EdDSA' }, oidc.None(), options));
|
|
78
223
|
}
|
|
79
224
|
function tokenRecord(tokens, previousRefresh) {
|
|
80
225
|
if (typeof tokens.expires_in !== 'number' || !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0)
|
|
@@ -142,7 +287,11 @@ async function openBrowser(url) {
|
|
|
142
287
|
});
|
|
143
288
|
}
|
|
144
289
|
export async function login(options) {
|
|
145
|
-
const
|
|
290
|
+
const source = canonicalResource(options.resource);
|
|
291
|
+
const metadata = await discoverResource(source);
|
|
292
|
+
const resource = metadata.restResource ?? source;
|
|
293
|
+
const { issuer } = resource === source ? metadata : await discoverResource(resource, true);
|
|
294
|
+
await checkIdentity(resource, { kind: 'anonymous' });
|
|
146
295
|
const store = await CredentialStore.open(options.stateDir);
|
|
147
296
|
let listener;
|
|
148
297
|
try {
|
|
@@ -151,14 +300,6 @@ export async function login(options) {
|
|
|
151
300
|
store.write(resource, { ...profile, epoch: profile.epoch + 1 });
|
|
152
301
|
return profile.epoch + 1;
|
|
153
302
|
});
|
|
154
|
-
const response = await boundedFetch(protectedResourceMetadataUrl(resource));
|
|
155
|
-
if (!response.ok)
|
|
156
|
-
throw new Error('Cannot discover the protected resource');
|
|
157
|
-
const metadata = resourceMetadata.parse(await response.json());
|
|
158
|
-
if (metadata.resource !== resource)
|
|
159
|
-
throw new Error('Protected resource metadata does not match the exact server URL');
|
|
160
|
-
const issuer = metadata.authorization_servers[0];
|
|
161
|
-
endpoint(issuer);
|
|
162
303
|
listener = await callback();
|
|
163
304
|
const { redirectUri, signal } = listener;
|
|
164
305
|
const config = await store.exclusive(async () => {
|
|
@@ -167,11 +308,10 @@ export async function login(options) {
|
|
|
167
308
|
throw new Error('Login was superseded by another login or logout');
|
|
168
309
|
if (profile.registration?.issuer === issuer)
|
|
169
310
|
return configuration(profile.registration, signal);
|
|
170
|
-
const registered = await oidc.dynamicClientRegistration(new URL(issuer), {
|
|
311
|
+
const registered = await oauth(issuer, signal, (options) => oidc.dynamicClientRegistration(new URL(issuer), {
|
|
171
312
|
client_name: 'Artifacts CLI', application_type: 'native', token_endpoint_auth_method: 'none', id_token_signed_response_alg: 'EdDSA',
|
|
172
313
|
redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], scope: scopes,
|
|
173
|
-
}, oidc.None(),
|
|
174
|
-
validateConfiguration(registered, issuer);
|
|
314
|
+
}, oidc.None(), options));
|
|
175
315
|
store.write(resource, { ...profile, registration: { issuer, clientId: registered.clientMetadata().client_id }, tokens: null, refreshAttempt: null });
|
|
176
316
|
return registered;
|
|
177
317
|
});
|
|
@@ -187,22 +327,21 @@ export async function login(options) {
|
|
|
187
327
|
if (!options.noBrowser)
|
|
188
328
|
await openBrowser(authorizationUrl);
|
|
189
329
|
const currentUrl = await authorization;
|
|
330
|
+
if (currentUrl.searchParams.has('error'))
|
|
331
|
+
throw new Error(currentUrl.searchParams.get('error') === 'access_denied' ? 'Authorization denied' : 'Authorization failed');
|
|
190
332
|
return await store.exclusive(async () => {
|
|
191
333
|
const profile = store.read(resource);
|
|
192
334
|
if (profile.epoch !== epoch)
|
|
193
335
|
throw new Error('Login was superseded by another login or logout');
|
|
194
|
-
const tokens = await
|
|
336
|
+
const tokens = await oauth(issuer, signal, (options) => {
|
|
337
|
+
config[oidc.customFetch] = options[oidc.customFetch];
|
|
338
|
+
return oidc.authorizationCodeGrant(config, currentUrl, { expectedState: state, expectedNonce: nonce, pkceCodeVerifier: verifier, idTokenExpected: true }, { resource });
|
|
339
|
+
});
|
|
195
340
|
const credentials = tokenRecord(tokens);
|
|
196
|
-
const
|
|
197
|
-
if (!identityResponse.ok)
|
|
198
|
-
throw new Error('The OAuth grant cannot access this Artifacts server');
|
|
199
|
-
const identity = identitySchema.parse(await identityResponse.json());
|
|
200
|
-
const claims = tokens.claims();
|
|
201
|
-
if (claims.sub !== identity.userId || (claims.sid !== undefined && claims.sid !== identity.sessionId))
|
|
202
|
-
throw new Error('OAuth identity does not match the Artifacts session');
|
|
341
|
+
const identity = await checkIdentity(resource, { kind: 'authenticated', accessToken: credentials.accessToken, signal, claims: tokens.claims() });
|
|
203
342
|
signal.throwIfAborted();
|
|
204
|
-
store.
|
|
205
|
-
return identity;
|
|
343
|
+
store.commitLogin(resource, epoch, credentials);
|
|
344
|
+
return { resource, identity };
|
|
206
345
|
});
|
|
207
346
|
}
|
|
208
347
|
finally {
|
package/src/credentials.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export declare class CredentialStore {
|
|
|
32
32
|
exclusive<T>(work: () => T | Promise<T>): Promise<T>;
|
|
33
33
|
read(resource: string): CredentialProfile;
|
|
34
34
|
write(resource: string, profile: CredentialProfile): void;
|
|
35
|
+
readSelectedResource(): string | undefined;
|
|
36
|
+
commitLogin(resource: string, epoch: number, tokens: Tokens): void;
|
|
35
37
|
close(): void;
|
|
36
38
|
}
|
|
37
39
|
export {};
|
package/src/credentials.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
5
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
+
import { canonicalResource } from '@yargote/artifacts-contracts/http';
|
|
7
8
|
const registrationSchema = z.strictObject({ issuer: z.string(), clientId: z.string().min(1) });
|
|
8
9
|
const tokensSchema = z.strictObject({ accessToken: z.string().min(1), refreshToken: z.string().min(1).optional(), expiresAt: z.number().finite() });
|
|
9
10
|
const profileSchema = z.strictObject({
|
|
@@ -50,6 +51,7 @@ export class CredentialStore {
|
|
|
50
51
|
await store.exclusive(() => {
|
|
51
52
|
data.exec('PRAGMA busy_timeout = 0; PRAGMA synchronous = FULL; PRAGMA secure_delete = ON;');
|
|
52
53
|
data.exec('CREATE TABLE IF NOT EXISTS profiles (resource TEXT PRIMARY KEY, profile TEXT NOT NULL) STRICT');
|
|
54
|
+
data.exec('CREATE TABLE IF NOT EXISTS selected_resource (id INTEGER PRIMARY KEY CHECK (id = 1), resource TEXT NOT NULL) STRICT');
|
|
53
55
|
});
|
|
54
56
|
return store;
|
|
55
57
|
}
|
|
@@ -90,5 +92,25 @@ export class CredentialStore {
|
|
|
90
92
|
write(resource, profile) {
|
|
91
93
|
this.data.prepare('INSERT INTO profiles(resource, profile) VALUES (?, ?) ON CONFLICT(resource) DO UPDATE SET profile = excluded.profile').run(resource, JSON.stringify(profileSchema.parse(profile)));
|
|
92
94
|
}
|
|
95
|
+
readSelectedResource() {
|
|
96
|
+
const row = this.data.prepare('SELECT resource FROM selected_resource WHERE id = 1').get();
|
|
97
|
+
return row ? canonicalResource(String(row.resource)) : undefined;
|
|
98
|
+
}
|
|
99
|
+
commitLogin(resource, epoch, tokens) {
|
|
100
|
+
canonicalResource(resource);
|
|
101
|
+
this.data.exec('BEGIN IMMEDIATE');
|
|
102
|
+
try {
|
|
103
|
+
const profile = this.read(resource);
|
|
104
|
+
if (profile.epoch !== epoch)
|
|
105
|
+
throw new Error('Login was superseded by another login or logout');
|
|
106
|
+
this.write(resource, { ...profile, tokens, refreshAttempt: null });
|
|
107
|
+
this.data.prepare('INSERT INTO selected_resource(id, resource) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET resource = excluded.resource').run(resource);
|
|
108
|
+
this.data.exec('COMMIT');
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
this.data.exec('ROLLBACK');
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
93
115
|
close() { this.data.close(); this.lock.close(); }
|
|
94
116
|
}
|
package/src/index.js
CHANGED
|
@@ -10,12 +10,14 @@ import { createSchema, listSharesSchema, moveSchema, publishSchema, shareSchema,
|
|
|
10
10
|
import { canonicalResource } from '@yargote/artifacts-contracts/http';
|
|
11
11
|
import { createArtifactHttpClient, HttpError, prepareArtifactEdit } from '@yargote/artifacts-client';
|
|
12
12
|
import { accessToken, login, logout } from "./auth.js";
|
|
13
|
+
import { CredentialStore } from "./credentials.js";
|
|
13
14
|
import { loadOperation, retainOperation, submitOperation } from "./operations.js";
|
|
14
15
|
import { runSkillsCommand } from "./skills.js";
|
|
15
16
|
const help = `Usage: artifacts COMMAND [OPTIONS]
|
|
16
17
|
|
|
17
18
|
Global options:
|
|
18
|
-
--server RESOURCE
|
|
19
|
+
--server RESOURCE Exact REST OAuth resource, or ARTIFACTS_SERVER
|
|
20
|
+
Defaults to the last successful login
|
|
19
21
|
--state-dir DIR Private local state, or ARTIFACTS_STATE_DIR
|
|
20
22
|
Default: ~/.local/state/artifacts
|
|
21
23
|
--help Print this reference
|
|
@@ -30,12 +32,14 @@ Commands:
|
|
|
30
32
|
login [--no-browser]
|
|
31
33
|
logout
|
|
32
34
|
whoami
|
|
33
|
-
create --snapshot FILE [--project NAME] [--request-id ID]
|
|
35
|
+
create --snapshot FILE [--project NAME] [--slug SLUG] [--request-id ID]
|
|
34
36
|
[--response full|compact]
|
|
35
37
|
create --command FILE
|
|
36
38
|
list [--limit N] [--cursor CURSOR] [--project NAME]
|
|
37
39
|
projects
|
|
38
40
|
read ID [--revision REV] [--response full|compact] [--out FILE [--quiet]]
|
|
41
|
+
read --slug SLUG [--project NAME] [--revision REV] [--response full|compact]
|
|
42
|
+
[--out FILE [--quiet]]
|
|
39
43
|
update ID --snapshot FILE --expected-revision REV [--request-id ID]
|
|
40
44
|
[--response full|compact]
|
|
41
45
|
update [ID] --command FILE
|
|
@@ -44,8 +48,8 @@ Commands:
|
|
|
44
48
|
publish ID --revision REV --expected-version N [--request-id ID]
|
|
45
49
|
publish [ID] --command FILE
|
|
46
50
|
export ID --revision REV --out FILE
|
|
47
|
-
move ID --project NAME [--
|
|
48
|
-
|
|
51
|
+
move ID [--project NAME | --no-project] [--slug SLUG | --no-slug]
|
|
52
|
+
[--request-id ID]
|
|
49
53
|
share ID --email EMAIL [--notify] [--request-id ID]
|
|
50
54
|
shares ID
|
|
51
55
|
unshare ID SHARE_ID [--request-id ID]
|
|
@@ -55,12 +59,29 @@ Mutation command files contain the complete create, update, or publish JSON.
|
|
|
55
59
|
Mutation request IDs default to a random UUID. Commands are retained before
|
|
56
60
|
submission. Retry sends the exact retained command for the same server and owner.
|
|
57
61
|
Projects group artifacts by name without regard to letter case. A project
|
|
58
|
-
exists while it contains an artifact. Move changes the project
|
|
59
|
-
without creating a revision
|
|
62
|
+
exists while it contains an artifact. Move changes the project or the slug of
|
|
63
|
+
an artifact without creating a revision. It needs at least one of its four
|
|
64
|
+
options. --no-project removes the artifact from its project, and --no-slug
|
|
65
|
+
clears its slug. An option left out keeps that value, so a move to another
|
|
66
|
+
project keeps the slug.
|
|
67
|
+
A slug such as concepts/rag names an artifact within its project, or within
|
|
68
|
+
the unfiled artifacts without --project. Read --slug finds it without its ID.
|
|
69
|
+
Create or move fails with slug_conflict when the project already uses the slug.
|
|
60
70
|
Read --out saves the returned artifact JSON. Export --out saves the ZIP.
|
|
61
71
|
Read --quiet requires --out and suppresses successful JSON stdout.
|
|
62
72
|
Output files are created exclusively and never overwrite an existing path.
|
|
63
73
|
JSON results go to stdout. Diagnostics and recovery guidance go to stderr.
|
|
74
|
+
Login accepts an MCP URL when its metadata advertises artifacts_rest_resource.
|
|
75
|
+
Split MCP and REST resources require this relation. Login remembers the verified
|
|
76
|
+
REST URL for later commands. --server takes precedence over ARTIFACTS_SERVER,
|
|
77
|
+
which takes precedence over the saved URL. Ordinary overrides do not replace
|
|
78
|
+
the saved URL. Logout keeps it for a later login.
|
|
79
|
+
Login checks <resource>/v1/identity before browser
|
|
80
|
+
authorization. Legacy /mcp
|
|
81
|
+
resources work when their REST API is at /mcp/v1. If the check fails, confirm
|
|
82
|
+
the configured REST URL with your service administrator.
|
|
83
|
+
Login errors report the phase, method, endpoint, and HTTP status when received.
|
|
84
|
+
Network failures have no HTTP status. Login verifies identity again after OAuth.
|
|
64
85
|
ARTIFACTS_ACCESS_TOKEN supplies a token for automation without local login.
|
|
65
86
|
Skills commands work offline without a server, login, or private state.
|
|
66
87
|
They print plain text. Global server and state options are accepted and ignored.
|
|
@@ -73,13 +94,13 @@ class UsageError extends Error {
|
|
|
73
94
|
}
|
|
74
95
|
const commandOptions = {
|
|
75
96
|
login: ['no-browser'], logout: [], whoami: [],
|
|
76
|
-
create: ['snapshot', 'project', 'request-id', 'response', 'command'],
|
|
77
|
-
list: ['limit', 'cursor', 'project'], projects: [], read: ['revision', 'response', 'out', 'quiet'],
|
|
97
|
+
create: ['snapshot', 'project', 'slug', 'request-id', 'response', 'command'],
|
|
98
|
+
list: ['limit', 'cursor', 'project'], projects: [], read: ['slug', 'project', 'revision', 'response', 'out', 'quiet'],
|
|
78
99
|
update: ['snapshot', 'expected-revision', 'request-id', 'response', 'command'],
|
|
79
100
|
edit: ['changes', 'request-id'], history: ['limit', 'before-sequence', 'summary'],
|
|
80
101
|
publish: ['revision', 'expected-version', 'request-id', 'command'],
|
|
81
102
|
share: ['email', 'notify', 'request-id'], shares: [], unshare: ['request-id'],
|
|
82
|
-
export: ['revision', 'out'], move: ['project', 'no-project', 'request-id'], retry: [],
|
|
103
|
+
export: ['revision', 'out'], move: ['project', 'no-project', 'slug', 'no-slug', 'request-id'], retry: [],
|
|
83
104
|
skills: ['full', 'all', 'agent', 'global', 'force'],
|
|
84
105
|
};
|
|
85
106
|
async function jsonFile(path) {
|
|
@@ -116,7 +137,7 @@ async function main() {
|
|
|
116
137
|
summary: { type: 'boolean' }, out: { type: 'string' }, quiet: { type: 'boolean' },
|
|
117
138
|
full: { type: 'boolean' }, all: { type: 'boolean' }, agent: { type: 'string' },
|
|
118
139
|
global: { type: 'boolean' }, force: { type: 'boolean' },
|
|
119
|
-
project: { type: 'string' }, 'no-project': { type: 'boolean' }, email: { type: 'string' }, notify: { type: 'boolean' },
|
|
140
|
+
project: { type: 'string' }, 'no-project': { type: 'boolean' }, slug: { type: 'string' }, 'no-slug': { type: 'boolean' }, email: { type: 'string' }, notify: { type: 'boolean' },
|
|
120
141
|
},
|
|
121
142
|
});
|
|
122
143
|
const seen = new Set();
|
|
@@ -144,11 +165,20 @@ async function main() {
|
|
|
144
165
|
throw new UsageError(`Option --${option} is not supported by ${command}`);
|
|
145
166
|
if (command === 'skills')
|
|
146
167
|
return runSkillsCommand(arguments_, values, seen);
|
|
147
|
-
const resourceValue = values.server ?? process.env.ARTIFACTS_SERVER;
|
|
148
|
-
if (!resourceValue)
|
|
149
|
-
throw new UsageError('Specify --server RESOURCE or ARTIFACTS_SERVER. No production server is selected by default.');
|
|
150
|
-
const resource = canonicalResource(resourceValue);
|
|
151
168
|
const stateDir = resolve(values['state-dir'] ?? process.env.ARTIFACTS_STATE_DIR ?? join(homedir(), '.local', 'state', 'artifacts'));
|
|
169
|
+
let resourceValue = values.server ?? process.env.ARTIFACTS_SERVER;
|
|
170
|
+
if (resourceValue === undefined) {
|
|
171
|
+
const store = await CredentialStore.open(stateDir);
|
|
172
|
+
try {
|
|
173
|
+
resourceValue = await store.exclusive(() => store.readSelectedResource());
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
store.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (resourceValue === undefined)
|
|
180
|
+
throw new UsageError('No server is saved. Run artifacts login --server RESOURCE or set ARTIFACTS_SERVER.');
|
|
181
|
+
const resource = canonicalResource(resourceValue);
|
|
152
182
|
function argument(required = true) {
|
|
153
183
|
if (arguments_.length > 1 || (required && arguments_.length !== 1))
|
|
154
184
|
throw new UsageError(`${command} requires ${required ? 'one' : 'zero or one'} positional argument`);
|
|
@@ -172,6 +202,11 @@ async function main() {
|
|
|
172
202
|
throw new UsageError(`--${name} requires a nonnegative safe integer`);
|
|
173
203
|
return Number(value);
|
|
174
204
|
}
|
|
205
|
+
function cleared(name, value, clear) {
|
|
206
|
+
if (value !== undefined && clear)
|
|
207
|
+
throw new UsageError(`Specify at most one of --${name} or --no-${name}`);
|
|
208
|
+
return clear ? null : value;
|
|
209
|
+
}
|
|
175
210
|
const response = values.response;
|
|
176
211
|
if (response !== undefined && response !== 'full' && response !== 'compact')
|
|
177
212
|
throw new UsageError('--response requires full or compact');
|
|
@@ -204,7 +239,12 @@ async function main() {
|
|
|
204
239
|
switch (command) {
|
|
205
240
|
case 'login':
|
|
206
241
|
none();
|
|
207
|
-
|
|
242
|
+
{
|
|
243
|
+
const result = await login({ resource, stateDir, noBrowser: values['no-browser'] ?? false, onAuthorizationUrl: (url) => { process.stderr.write(`Authorize this login in your browser:\n${url}\n`); } });
|
|
244
|
+
if (result.resource !== resource)
|
|
245
|
+
process.stderr.write(`REST resource: ${result.resource}\nThis verified REST URL is saved for subsequent commands.\n`);
|
|
246
|
+
return result.identity;
|
|
247
|
+
}
|
|
208
248
|
case 'logout':
|
|
209
249
|
none();
|
|
210
250
|
return logout({ resource, stateDir });
|
|
@@ -215,7 +255,7 @@ async function main() {
|
|
|
215
255
|
if (values.command)
|
|
216
256
|
return mutate(await commandFile('create'));
|
|
217
257
|
none();
|
|
218
|
-
return mutate({ kind: 'create', command: createSchema.parse({ requestId: values['request-id'] ?? randomUUID(), snapshot: await jsonFile(required('snapshot')), ...(values.project === undefined ? {} : { project: values.project }), ...(response === undefined ? {} : { response }) }) });
|
|
258
|
+
return mutate({ kind: 'create', command: createSchema.parse({ requestId: values['request-id'] ?? randomUUID(), snapshot: await jsonFile(required('snapshot')), ...(values.project === undefined ? {} : { project: values.project }), ...(values.slug === undefined ? {} : { slug: values.slug }), ...(response === undefined ? {} : { response }) }) });
|
|
219
259
|
}
|
|
220
260
|
case 'list':
|
|
221
261
|
none();
|
|
@@ -224,7 +264,12 @@ async function main() {
|
|
|
224
264
|
none();
|
|
225
265
|
return client.projects();
|
|
226
266
|
case 'read': {
|
|
227
|
-
|
|
267
|
+
if (values.slug === undefined && values.project !== undefined)
|
|
268
|
+
throw new UsageError('read --project requires --slug');
|
|
269
|
+
if ((values.slug === undefined) !== (arguments_.length === 1) || arguments_.length > 1)
|
|
270
|
+
throw new UsageError('read requires exactly one of an artifact ID or --slug SLUG');
|
|
271
|
+
const target = values.slug === undefined ? { artifactId: arguments_[0] } : { slug: values.slug, ...(values.project === undefined ? {} : { project: values.project }) };
|
|
272
|
+
const result = await client.read({ ...target, ...(values.revision === undefined ? {} : { revisionId: values.revision }), ...(response === undefined ? {} : { response }) });
|
|
228
273
|
if (values.out)
|
|
229
274
|
await writeOutput(values.out, `${JSON.stringify(result, null, 2)}\n`);
|
|
230
275
|
return quiet ? undefined : result;
|
|
@@ -255,9 +300,11 @@ async function main() {
|
|
|
255
300
|
}
|
|
256
301
|
case 'move': {
|
|
257
302
|
const artifactId = argument();
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
303
|
+
const project = cleared('project', values.project, values['no-project']);
|
|
304
|
+
const slug = cleared('slug', values.slug, values['no-slug']);
|
|
305
|
+
if (project === undefined && slug === undefined)
|
|
306
|
+
throw new UsageError('move requires --project NAME, --no-project, --slug SLUG, or --no-slug');
|
|
307
|
+
return mutate({ kind: 'move', command: moveSchema.parse({ artifactId, requestId: values['request-id'] ?? randomUUID(), ...(project === undefined ? {} : { project }), ...(slug === undefined ? {} : { slug }) }) });
|
|
261
308
|
}
|
|
262
309
|
case 'share': return mutate({ kind: 'share', command: shareSchema.parse({ artifactId: argument(), requestId: values['request-id'] ?? randomUUID(), email: required('email'), ...(values.notify === undefined ? {} : { notify: values.notify }) }) });
|
|
263
310
|
case 'shares': return client.listShares(listSharesSchema.parse({ artifactId: argument() }));
|
package/src/operations.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
31
31
|
}, z.core.$strict>>;
|
|
32
32
|
}, z.core.$strict>;
|
|
33
33
|
project: z.ZodOptional<z.ZodString>;
|
|
34
|
+
slug: z.ZodOptional<z.ZodString>;
|
|
34
35
|
response: z.ZodOptional<z.ZodEnum<{
|
|
35
36
|
full: "full";
|
|
36
37
|
compact: "compact";
|
|
@@ -94,7 +95,8 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
94
95
|
command: z.ZodObject<{
|
|
95
96
|
requestId: z.ZodString;
|
|
96
97
|
artifactId: z.ZodString;
|
|
97
|
-
project: z.ZodNullable<z.ZodString
|
|
98
|
+
project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
99
|
+
slug: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
98
100
|
}, z.core.$strict>;
|
|
99
101
|
version: z.ZodLiteral<1>;
|
|
100
102
|
resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|