@yargote/artifacts-cli 0.0.0-stage → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yerson Argote
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,179 @@
1
- # Temporary Holding Version
1
+ # Artifacts CLI reference
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The `artifacts` Node CLI reads and writes retained artifacts through REST. It prints JSON results to stdout and diagnostics to stderr. Node 24 or later is required. Its private credential database uses Node's experimental built-in SQLite API.
4
+
5
+ The `@yargote/artifacts-cli` package exposes the `artifacts` binary as compiled ESM JavaScript. Its client and contracts dependencies install automatically. Repository development uses `pnpm artifacts` with the same command syntax.
6
+
7
+ ## Install the beta
8
+
9
+ Use Node 24 or later. The beta targets macOS and Linux. Windows has not been verified.
10
+
11
+ Choose one persistent installation:
12
+
13
+ ```sh
14
+ npm install --global @yargote/artifacts-cli@0.1.0-beta.1
15
+ # Or, with pnpm and an existing global bin directory on PATH:
16
+ pnpm add --global @yargote/artifacts-cli@0.1.0-beta.1
17
+ artifacts --version
18
+ ```
19
+
20
+ If pnpm reports that no global bin directory exists, run `pnpm setup` and reopen your shell before installation.
21
+
22
+ For one-off execution, pin the same version:
23
+
24
+ ```sh
25
+ npx --yes @yargote/artifacts-cli@0.1.0-beta.1 --help
26
+ pnpm dlx @yargote/artifacts-cli@0.1.0-beta.1 --help
27
+ ```
28
+
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
+
31
+ ## Complete first use
32
+
33
+ An existing Artifacts account is required. Select the complete OAuth resource, including `/mcp`:
34
+
35
+ ```sh
36
+ export ARTIFACTS_SERVER='https://artifacts-mcp.yersonargotev.workers.dev/mcp'
37
+ artifacts login
38
+ artifacts whoami
39
+ ```
40
+
41
+ Login opens browser authorization. After you approve access, `whoami` prints JSON with your user ID and scopes. Use `artifacts login --no-browser` to open the printed authorization URL yourself on the same machine.
42
+
43
+ Create `snapshot.json`:
44
+
45
+ ```json
46
+ {
47
+ "metadata": { "title": "My first CLI artifact", "description": "Created through REST" },
48
+ "content": { "kind": "text", "text": "Hello from the Artifacts CLI." },
49
+ "files": []
50
+ }
51
+ ```
52
+
53
+ ```sh
54
+ artifacts create --snapshot snapshot.json
55
+ artifacts list --limit 5
56
+ ```
57
+
58
+ The create result contains `artifactId` and `headRevisionId`. Use those values to read, publish, and export the exact revision:
59
+
60
+ ```sh
61
+ artifacts read ARTIFACT_ID
62
+ artifacts publish ARTIFACT_ID --revision REVISION_ID --expected-version 0
63
+ artifacts export ARTIFACT_ID --revision REVISION_ID --out artifact.zip
64
+ artifacts logout
65
+ ```
66
+
67
+ Publication URLs require authenticated owner access. A retained request ID appears on stderr before each mutation. If the response is lost, run `artifacts retry REQUEST_ID` with the same server and state directory. Keep that state directory when upgrading, reinstalling, or switching between global and one-off execution.
68
+
69
+ ## Upgrade or uninstall
70
+
71
+ Install another exact version with the same package manager to change the executable version. Credentials and retained operation files live outside the package installation and remain available.
72
+
73
+ ```sh
74
+ npm uninstall --global @yargote/artifacts-cli
75
+ # Or:
76
+ pnpm remove --global @yargote/artifacts-cli
77
+ ```
78
+
79
+ Run `artifacts logout` before uninstalling if you want to clear credentials. Uninstalling does not delete retained operations.
80
+
81
+ ## Server and local state
82
+
83
+ `--server RESOURCE` selects the exact canonical OAuth resource. `ARTIFACTS_SERVER` supplies the same value. A resource ending in `/mcp` uses REST under `/mcp/v1`. The CLI has no production default.
84
+
85
+ `--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 are 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.
86
+
87
+ 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.
88
+
89
+ ## Authentication
90
+
91
+ `artifacts login --server RESOURCE` verifies protected-resource metadata, registers a public native OAuth client, and opens browser authorization with PKCE. `--no-browser` 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.
92
+
93
+ `artifacts whoami` returns the verified user, session, OAuth client, and scopes.
94
+
95
+ `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.
96
+
97
+ `ARTIFACTS_ACCESS_TOKEN` supplies an access token for automation. It does not require local login. Tokens are never command arguments or retained operation fields.
98
+
99
+ The private `credentials.sqlite` file stores credentials. The separate `credentials-lock.sqlite` file contains no credentials and holds a SQLite write lock to serialize refresh and logout across processes. A committed uncertainty marker in the data file survives process death, while SQLite releases the lock. Login and logout generations prevent a late browser callback from restoring signed-out credentials. An uncertain refresh requires login.
100
+
101
+ ## Command syntax
102
+
103
+ `artifacts --help` prints this command reference. `artifacts --version` prints the installed package version without requiring a server or login.
104
+
105
+ ```text
106
+ artifacts login [--no-browser]
107
+ artifacts logout
108
+ artifacts whoami
109
+ artifacts create --snapshot FILE [--request-id ID] [--response full|compact]
110
+ artifacts create --command FILE
111
+ artifacts list [--limit N] [--cursor CURSOR]
112
+ artifacts read ID [--revision REV] [--response full|compact] [--out FILE]
113
+ artifacts update ID --snapshot FILE --expected-revision REV [--request-id ID]
114
+ [--response full|compact]
115
+ artifacts update [ID] --command FILE
116
+ artifacts edit ID --changes FILE [--request-id ID]
117
+ artifacts history ID [--limit N] [--before-sequence N] [--summary]
118
+ artifacts publish ID --revision REV --expected-version N [--request-id ID]
119
+ artifacts publish [ID] --command FILE
120
+ artifacts export ID --revision REV --out FILE
121
+ artifacts retry REQUEST_ID
122
+ ```
123
+
124
+ Global `--server RESOURCE` and `--state-dir DIR` options can appear before or after the command. Options cannot be repeated. Unknown options and options for another command fail before submission.
125
+
126
+ Create and update snapshot files contain the complete domain snapshot.
127
+
128
+ ```json
129
+ {
130
+ "metadata": { "title": "Example", "description": "A retained document", "custom": { "team": "design" } },
131
+ "content": { "kind": "html", "text": "<h1>Example</h1>" },
132
+ "files": [{ "path": "data.txt", "mediaType": "text/plain", "encoding": "utf8", "data": "retained attachment" }]
133
+ }
134
+ ```
135
+
136
+ `read --out FILE` saves the returned artifact JSON and also prints it to stdout. A default full read contains the complete snapshot under `revision.snapshot`. `export --out FILE` saves the exact ZIP and prints its filename, local path, media type, SHA-256, and byte count. Output files use exclusive creation and never overwrite an existing path.
137
+
138
+ History defaults to complete revisions. `--summary` returns revision metadata and attachment summaries. `--before-sequence` continues a page. `list --cursor` continues an artifact list.
139
+
140
+ ## Complete commands and edits
141
+
142
+ `--command FILE` accepts a complete create, update, or publish command. It cannot be combined with other mutation options. For update and publish, an optional positional artifact ID must equal the command's `artifactId`. Every field, including the omission of `response`, is retained.
143
+
144
+ A complete update command has this shape.
145
+
146
+ ```json
147
+ {
148
+ "requestId": "reviewed-update-001",
149
+ "artifactId": "artifact-id",
150
+ "expectedRevisionId": "reviewed-revision-id",
151
+ "snapshot": {
152
+ "metadata": { "title": "Reviewed title", "description": "Complete replacement" },
153
+ "content": { "kind": "text", "text": "Reviewed text" },
154
+ "files": []
155
+ }
156
+ }
157
+ ```
158
+
159
+ `edit ID --changes FILE` reads the current snapshot, merges partial `metadata` and `content` changes, and retains the resulting complete update. An explicit `files` array replaces the attachments. Omitted fields and attachments are preserved.
160
+
161
+ ```json
162
+ { "metadata": { "title": "Changed title" }, "content": { "text": "Changed text" } }
163
+ ```
164
+
165
+ Publish always selects an immutable revision. `--expected-version 0` expects no existing publication. Later publication commands require the current publication version.
166
+
167
+ ## Recovery
168
+
169
+ Each invocation resolves one access token for its reads, owner check, and submission. A concurrent login cannot change the command owner between these requests. Every mutation verifies the owner and retains an exclusive private operation file before submission. Missing request IDs default to random UUIDs. The operation stores its exact command, owner, and resource. It contains no credentials. Existing operation files require explicit `retry` and are never replaced by another preparation.
170
+
171
+ `retry REQUEST_ID` verifies the current resource and owner, then sends the unchanged command once. Retry does not read a new head, change expected versions, add a response projection, or rebase a conflict. Its diagnostic identifies the result as a retained receipt. A fresh `read` returns current state.
172
+
173
+ If a response is lost after commit, retry recovers the original receipt. If a conflict occurs, the retained operation remains available for inspection. A new read and a deliberate new command with a new request ID are required to incorporate another writer's changes.
174
+
175
+ Commands exit with code `1` on failure and print a JSON error to stderr. Server errors preserve their HTTP status, code, and conflict details. Successful JSON stdout contains no progress messages or credentials.
176
+
177
+ ## License and support
178
+
179
+ This package is MIT licensed. The client and contracts packages have their own MIT licenses. This does not license the private Artifacts application repository. Contact your Artifacts service administrator with `artifacts --version`, Node and package-manager versions, and a redacted error. Never include credentials or the contents of `credentials.sqlite`.
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@yargote/artifacts-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Command-line client for the Artifacts REST API.",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/yersonargotev/artifacts.git",
11
+ "directory": "apps/cli"
12
+ },
13
+ "homepage": "https://github.com/yersonargotev",
14
+ "bugs": {
15
+ "url": "https://github.com/yersonargotev"
16
+ },
17
+ "engines": {
18
+ "node": ">=24"
19
+ },
20
+ "bin": {
21
+ "artifacts": "./src/index.js"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "dependencies": {
29
+ "@yargote/artifacts-contracts": "0.1.0-beta.1",
30
+ "@yargote/artifacts-client": "0.1.0-beta.1",
31
+ "openid-client": "6.8.8",
32
+ "zod": "4.6.5"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "tag": "beta",
37
+ "provenance": false
38
+ }
39
+ }
package/src/auth.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { type Identity } from '@yargote/artifacts-contracts/http';
2
+ export declare function login(options: {
3
+ resource: string;
4
+ stateDir: string;
5
+ noBrowser: boolean;
6
+ onAuthorizationUrl: (url: string) => void;
7
+ }): Promise<Identity>;
8
+ export declare function accessToken(options: {
9
+ resource: string;
10
+ stateDir: string;
11
+ }): Promise<string>;
12
+ export declare function logout(options: {
13
+ resource: string;
14
+ stateDir: string;
15
+ }): Promise<{
16
+ signedOut: true;
17
+ remoteRevocation: 'confirmed' | 'unconfirmed' | 'not-needed';
18
+ }>;
package/src/auth.js ADDED
@@ -0,0 +1,276 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { createServer } from 'node:http';
4
+ import * as oidc from 'openid-client';
5
+ import { canonicalResource, identitySchema, protectedResourceMetadataUrl, restBase } from '@yargote/artifacts-contracts/http';
6
+ import { z } from 'zod';
7
+ import { CredentialStore } from "./credentials.js";
8
+ const networkTimeout = 10_000;
9
+ const callbackTimeout = 180_000;
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
+ function endpoint(value, issuer) {
13
+ const url = new URL(value);
14
+ const loopback = /^http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost)(?::[0-9]+)?(?:\/|$)/.test(value);
15
+ if (value.trim() !== value || url.username || url.password || url.search || url.hash || (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)))
16
+ throw new Error('OAuth URLs require HTTPS or an exact loopback HTTP host, without credentials, query, or fragment');
17
+ if (issuer && url.origin !== new URL(issuer).origin)
18
+ throw new Error('OAuth endpoints must use the issuer origin');
19
+ return url;
20
+ }
21
+ async function boundedFetch(url, options = {}) {
22
+ const signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(networkTimeout)]) : AbortSignal.timeout(networkTimeout);
23
+ const response = await fetch(url, { ...options, redirect: 'manual', signal });
24
+ if (response.status >= 300 && response.status < 400) {
25
+ await response.body?.cancel();
26
+ throw new Error('Authentication redirects are not allowed');
27
+ }
28
+ const reader = response.body?.getReader();
29
+ const chunks = [];
30
+ let size = 0;
31
+ if (reader) {
32
+ try {
33
+ for (;;) {
34
+ const chunk = await reader.read();
35
+ if (chunk.done)
36
+ break;
37
+ size += chunk.value.byteLength;
38
+ if (size > 1024 * 1024)
39
+ throw new Error('Authentication response is too large');
40
+ chunks.push(chunk.value);
41
+ }
42
+ }
43
+ catch (error) {
44
+ await reader.cancel().catch(() => { });
45
+ throw error;
46
+ }
47
+ finally {
48
+ reader.releaseLock();
49
+ }
50
+ }
51
+ return new Response(size ? Buffer.concat(chunks) : null, { status: response.status, statusText: response.statusText, headers: response.headers });
52
+ }
53
+ function discoveryOptions(issuer, signal) {
54
+ endpoint(issuer);
55
+ return {
56
+ timeout: networkTimeout / 1000,
57
+ execute: [...(new URL(issuer).protocol === 'http:' ? [oidc.allowInsecureRequests] : []), oidc.enableNonRepudiationChecks],
58
+ [oidc.customFetch]: (url, options) => boundedFetch(endpoint(url, issuer), { ...options, signal: signal && options.signal ? AbortSignal.any([signal, options.signal]) : signal ?? options.signal, body: options.body instanceof Uint8Array ? Uint8Array.from(options.body) : options.body }),
59
+ };
60
+ }
61
+ function validateConfiguration(config, issuer) {
62
+ const metadata = config.serverMetadata();
63
+ if (metadata.issuer !== issuer)
64
+ throw new Error('OAuth issuer metadata does not match the configured issuer');
65
+ for (const [key, value] of Object.entries(metadata)) {
66
+ if ((key.endsWith('_endpoint') || key === 'jwks_uri') && typeof value === 'string')
67
+ endpoint(value, issuer);
68
+ }
69
+ if (!metadata.authorization_endpoint || !metadata.token_endpoint)
70
+ throw new Error('OAuth metadata is missing authorization or token endpoints');
71
+ if (config.clientMetadata().client_secret || config.clientMetadata().token_endpoint_auth_method !== 'none')
72
+ throw new Error('The CLI requires a public OAuth client without a secret');
73
+ }
74
+ async function configuration(registration, signal) {
75
+ const config = await oidc.discovery(new URL(registration.issuer), registration.clientId, { token_endpoint_auth_method: 'none', id_token_signed_response_alg: 'EdDSA' }, oidc.None(), discoveryOptions(registration.issuer, signal));
76
+ validateConfiguration(config, registration.issuer);
77
+ return config;
78
+ }
79
+ function tokenRecord(tokens, previousRefresh) {
80
+ if (typeof tokens.expires_in !== 'number' || !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0)
81
+ throw new Error('OAuth token response is missing a valid expiration');
82
+ return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? previousRefresh, expiresAt: Date.now() + tokens.expires_in * 1000 };
83
+ }
84
+ async function callback() {
85
+ const controller = new AbortController();
86
+ let accept;
87
+ let reject;
88
+ let expectedState;
89
+ let expectedIssuer;
90
+ let redirectUri = '';
91
+ let settled = false;
92
+ const result = new Promise((resolve, fail) => { accept = resolve; reject = fail; });
93
+ void result.catch(() => { });
94
+ const server = createServer((request, response) => {
95
+ response.setHeader('cache-control', 'no-store');
96
+ response.setHeader('content-type', 'text/plain; charset=utf-8');
97
+ if (request.method !== 'GET' || !request.url || request.url.length > 8192 || request.headers.host !== new URL(redirectUri).host || request.url.split('?')[0] !== '/oauth/callback') {
98
+ response.writeHead(404).end('Not found');
99
+ return;
100
+ }
101
+ const url = new URL(request.url, redirectUri);
102
+ if (settled || !expectedState || url.searchParams.getAll('state').length !== 1 || url.searchParams.get('state') !== expectedState || url.searchParams.getAll('iss').length !== 1 || url.searchParams.get('iss') !== expectedIssuer || url.searchParams.getAll('code').length + url.searchParams.getAll('error').length !== 1) {
103
+ response.writeHead(400).end('Invalid OAuth callback');
104
+ return;
105
+ }
106
+ settled = true;
107
+ response.end('Authorization received. Return to the terminal to check the result.');
108
+ accept(url);
109
+ });
110
+ await new Promise((resolve, fail) => {
111
+ server.once('error', fail);
112
+ server.listen(0, '127.0.0.1', () => { server.removeListener('error', fail); resolve(); });
113
+ });
114
+ const address = server.address();
115
+ if (!address || typeof address === 'string')
116
+ throw new Error('Cannot bind the OAuth loopback callback');
117
+ redirectUri = `http://127.0.0.1:${address.port}/oauth/callback`;
118
+ const fail = (error) => { controller.abort(error); reject(error); };
119
+ const timer = setTimeout(() => fail(new Error('Login timed out. Run artifacts login again')), callbackTimeout);
120
+ const cancel = () => fail(new Error('Login cancelled'));
121
+ process.once('SIGINT', cancel);
122
+ process.once('SIGTERM', cancel);
123
+ return {
124
+ redirectUri, signal: controller.signal,
125
+ wait(state, issuer) { expectedState = state; expectedIssuer = issuer; return result; },
126
+ async close() {
127
+ clearTimeout(timer);
128
+ process.removeListener('SIGINT', cancel);
129
+ process.removeListener('SIGTERM', cancel);
130
+ server.closeAllConnections();
131
+ await new Promise((resolve) => server.close(() => resolve()));
132
+ },
133
+ };
134
+ }
135
+ async function openBrowser(url) {
136
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
137
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
138
+ await new Promise((resolve, reject) => {
139
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
140
+ child.once('error', () => reject(new Error('Cannot open a browser. Run artifacts login with --no-browser')));
141
+ child.once('spawn', () => { child.unref(); resolve(); });
142
+ });
143
+ }
144
+ export async function login(options) {
145
+ const resource = canonicalResource(options.resource);
146
+ const store = await CredentialStore.open(options.stateDir);
147
+ let listener;
148
+ try {
149
+ const epoch = await store.exclusive(() => {
150
+ const profile = store.read(resource);
151
+ store.write(resource, { ...profile, epoch: profile.epoch + 1 });
152
+ return profile.epoch + 1;
153
+ });
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
+ listener = await callback();
163
+ const { redirectUri, signal } = listener;
164
+ const config = await store.exclusive(async () => {
165
+ const profile = store.read(resource);
166
+ if (profile.epoch !== epoch)
167
+ throw new Error('Login was superseded by another login or logout');
168
+ if (profile.registration?.issuer === issuer)
169
+ return configuration(profile.registration, signal);
170
+ const registered = await oidc.dynamicClientRegistration(new URL(issuer), {
171
+ client_name: 'Artifacts CLI', application_type: 'native', token_endpoint_auth_method: 'none', id_token_signed_response_alg: 'EdDSA',
172
+ redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], scope: scopes,
173
+ }, oidc.None(), discoveryOptions(issuer, signal));
174
+ validateConfiguration(registered, issuer);
175
+ store.write(resource, { ...profile, registration: { issuer, clientId: registered.clientMetadata().client_id }, tokens: null, refreshAttempt: null });
176
+ return registered;
177
+ });
178
+ const verifier = oidc.randomPKCECodeVerifier();
179
+ const state = oidc.randomState();
180
+ const nonce = oidc.randomNonce();
181
+ const authorizationUrl = oidc.buildAuthorizationUrl(config, {
182
+ redirect_uri: redirectUri, response_type: 'code', scope: scopes, resource,
183
+ code_challenge: await oidc.calculatePKCECodeChallenge(verifier), code_challenge_method: 'S256', state, nonce,
184
+ }).href;
185
+ const authorization = listener.wait(state, issuer);
186
+ options.onAuthorizationUrl(authorizationUrl);
187
+ if (!options.noBrowser)
188
+ await openBrowser(authorizationUrl);
189
+ const currentUrl = await authorization;
190
+ return await store.exclusive(async () => {
191
+ const profile = store.read(resource);
192
+ if (profile.epoch !== epoch)
193
+ throw new Error('Login was superseded by another login or logout');
194
+ const tokens = await oidc.authorizationCodeGrant(config, currentUrl, { expectedState: state, expectedNonce: nonce, pkceCodeVerifier: verifier, idTokenExpected: true }, { resource });
195
+ const credentials = tokenRecord(tokens);
196
+ const identityResponse = await boundedFetch(`${restBase(resource)}/identity`, { signal, headers: { authorization: `Bearer ${credentials.accessToken}` } });
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');
203
+ signal.throwIfAborted();
204
+ store.write(resource, { ...profile, tokens: credentials, refreshAttempt: null });
205
+ return identity;
206
+ });
207
+ }
208
+ finally {
209
+ await listener?.close();
210
+ store.close();
211
+ }
212
+ }
213
+ export async function accessToken(options) {
214
+ const resource = canonicalResource(options.resource);
215
+ const automationToken = process.env.ARTIFACTS_ACCESS_TOKEN;
216
+ if (automationToken !== undefined) {
217
+ if (!/^[A-Za-z0-9._~+/-]+=*$/.test(automationToken))
218
+ throw new Error('ARTIFACTS_ACCESS_TOKEN must contain a nonempty bearer token');
219
+ return automationToken;
220
+ }
221
+ const store = await CredentialStore.open(options.stateDir);
222
+ try {
223
+ return await store.exclusive(async () => {
224
+ const profile = store.read(resource);
225
+ const required = `Run artifacts login --server ${resource}`;
226
+ if (!profile.tokens || !profile.registration)
227
+ throw new Error(`You are not signed in. ${required}`);
228
+ if (profile.refreshAttempt)
229
+ throw new Error(`The previous token refresh has an uncertain result. ${required}`);
230
+ if (profile.tokens.expiresAt > Date.now() + 30_000)
231
+ return profile.tokens.accessToken;
232
+ if (!profile.tokens.refreshToken)
233
+ throw new Error(`Your session expired. ${required}`);
234
+ const config = await configuration(profile.registration);
235
+ // The separate SQLite lock survives this commit and releases automatically on process death.
236
+ store.write(resource, { ...profile, refreshAttempt: randomUUID() });
237
+ try {
238
+ const tokens = await oidc.refreshTokenGrant(config, profile.tokens.refreshToken, { resource });
239
+ const credentials = tokenRecord(tokens, profile.tokens.refreshToken);
240
+ store.write(resource, { ...profile, tokens: credentials, refreshAttempt: null });
241
+ return credentials.accessToken;
242
+ }
243
+ catch {
244
+ throw new Error(`Token refresh failed and its result is uncertain. ${required}`);
245
+ }
246
+ });
247
+ }
248
+ finally {
249
+ store.close();
250
+ }
251
+ }
252
+ export async function logout(options) {
253
+ const resource = canonicalResource(options.resource);
254
+ const store = await CredentialStore.open(options.stateDir);
255
+ let previous;
256
+ try {
257
+ previous = await store.exclusive(() => {
258
+ const profile = store.read(resource);
259
+ store.write(resource, { ...profile, epoch: profile.epoch + 1, tokens: null, refreshAttempt: null });
260
+ return profile;
261
+ });
262
+ }
263
+ finally {
264
+ store.close();
265
+ }
266
+ if (!previous.tokens?.refreshToken || !previous.registration)
267
+ return { signedOut: true, remoteRevocation: 'not-needed' };
268
+ try {
269
+ const config = await configuration(previous.registration);
270
+ await oidc.tokenRevocation(config, previous.tokens.refreshToken, { token_type_hint: 'refresh_token' });
271
+ return { signedOut: true, remoteRevocation: previous.refreshAttempt ? 'unconfirmed' : 'confirmed' };
272
+ }
273
+ catch {
274
+ return { signedOut: true, remoteRevocation: 'unconfirmed' };
275
+ }
276
+ }
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod';
2
+ declare const registrationSchema: z.ZodObject<{
3
+ issuer: z.ZodString;
4
+ clientId: z.ZodString;
5
+ }, z.core.$strict>;
6
+ declare const tokensSchema: z.ZodObject<{
7
+ accessToken: z.ZodString;
8
+ refreshToken: z.ZodOptional<z.ZodString>;
9
+ expiresAt: z.ZodNumber;
10
+ }, z.core.$strict>;
11
+ declare const profileSchema: z.ZodObject<{
12
+ epoch: z.ZodNumber;
13
+ registration: z.ZodNullable<z.ZodObject<{
14
+ issuer: z.ZodString;
15
+ clientId: z.ZodString;
16
+ }, z.core.$strict>>;
17
+ tokens: z.ZodNullable<z.ZodObject<{
18
+ accessToken: z.ZodString;
19
+ refreshToken: z.ZodOptional<z.ZodString>;
20
+ expiresAt: z.ZodNumber;
21
+ }, z.core.$strict>>;
22
+ refreshAttempt: z.ZodNullable<z.ZodString>;
23
+ }, z.core.$strict>;
24
+ export type Registration = z.infer<typeof registrationSchema>;
25
+ export type Tokens = z.infer<typeof tokensSchema>;
26
+ export type CredentialProfile = z.infer<typeof profileSchema>;
27
+ export declare class CredentialStore {
28
+ private readonly data;
29
+ private readonly lock;
30
+ private constructor();
31
+ static open(stateDir: string): Promise<CredentialStore>;
32
+ exclusive<T>(work: () => T | Promise<T>): Promise<T>;
33
+ read(resource: string): CredentialProfile;
34
+ write(resource: string, profile: CredentialProfile): void;
35
+ close(): void;
36
+ }
37
+ export {};
@@ -0,0 +1,94 @@
1
+ import { constants } from 'node:fs';
2
+ import { chmod, lstat, mkdir, open } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { z } from 'zod';
7
+ const registrationSchema = z.strictObject({ issuer: z.string(), clientId: z.string().min(1) });
8
+ const tokensSchema = z.strictObject({ accessToken: z.string().min(1), refreshToken: z.string().min(1).optional(), expiresAt: z.number().finite() });
9
+ const profileSchema = z.strictObject({
10
+ epoch: z.number().int().nonnegative(),
11
+ registration: registrationSchema.nullable(),
12
+ tokens: tokensSchema.nullable(),
13
+ refreshAttempt: z.string().nullable(),
14
+ });
15
+ async function privateFile(path) {
16
+ const file = await open(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
17
+ try {
18
+ const stat = await file.stat();
19
+ if (!stat.isFile() || stat.nlink !== 1 || (process.getuid && stat.uid !== process.getuid()))
20
+ throw new Error('Credential storage must be a private file owned by the current user');
21
+ await file.chmod(0o600);
22
+ }
23
+ finally {
24
+ await file.close();
25
+ }
26
+ }
27
+ export class CredentialStore {
28
+ data;
29
+ lock;
30
+ constructor(data, lock) {
31
+ this.data = data;
32
+ this.lock = lock;
33
+ }
34
+ static async open(stateDir) {
35
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
36
+ const directory = await lstat(stateDir);
37
+ if (!directory.isDirectory() || directory.isSymbolicLink() || (process.getuid && directory.uid !== process.getuid()))
38
+ throw new Error('Credential storage must be a private directory owned by the current user');
39
+ await chmod(stateDir, 0o700);
40
+ const dataPath = join(stateDir, 'credentials.sqlite');
41
+ const lockPath = join(stateDir, 'credentials-lock.sqlite');
42
+ await privateFile(dataPath);
43
+ await privateFile(lockPath);
44
+ const lock = new DatabaseSync(lockPath);
45
+ let data;
46
+ try {
47
+ data = new DatabaseSync(dataPath);
48
+ lock.exec('PRAGMA busy_timeout = 0;');
49
+ const store = new CredentialStore(data, lock);
50
+ await store.exclusive(() => {
51
+ data.exec('PRAGMA busy_timeout = 0; PRAGMA synchronous = FULL; PRAGMA secure_delete = ON;');
52
+ data.exec('CREATE TABLE IF NOT EXISTS profiles (resource TEXT PRIMARY KEY, profile TEXT NOT NULL) STRICT');
53
+ });
54
+ return store;
55
+ }
56
+ catch (error) {
57
+ data?.close();
58
+ lock.close();
59
+ throw error;
60
+ }
61
+ }
62
+ async exclusive(work) {
63
+ const deadline = Date.now() + 45_000;
64
+ for (;;) {
65
+ try {
66
+ this.lock.exec('BEGIN IMMEDIATE');
67
+ break;
68
+ }
69
+ catch (error) {
70
+ if (!(error instanceof Error) || !('errcode' in error) || (error.errcode !== 5 && error.errcode !== 6))
71
+ throw error;
72
+ if (Date.now() >= deadline)
73
+ throw new Error('Credential storage is busy. Try the command again');
74
+ await delay(25);
75
+ }
76
+ }
77
+ try {
78
+ return await work();
79
+ }
80
+ finally {
81
+ this.lock.exec('ROLLBACK');
82
+ }
83
+ }
84
+ read(resource) {
85
+ const row = this.data.prepare('SELECT profile FROM profiles WHERE resource = ?').get(resource);
86
+ if (!row)
87
+ return { epoch: 0, registration: null, tokens: null, refreshAttempt: null };
88
+ return profileSchema.parse(JSON.parse(String(row.profile)));
89
+ }
90
+ write(resource, profile) {
91
+ 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
+ }
93
+ close() { this.data.close(); this.lock.close(); }
94
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/src/index.js ADDED
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { open, readFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import { parseArgs } from 'node:util';
8
+ import { z } from 'zod';
9
+ import { createSchema, publishSchema, updateSchema } from '@yargote/artifacts-contracts';
10
+ import { canonicalResource } from '@yargote/artifacts-contracts/http';
11
+ import { createArtifactHttpClient, HttpError, prepareArtifactEdit } from '@yargote/artifacts-client';
12
+ import { accessToken, login, logout } from "./auth.js";
13
+ import { loadOperation, retainOperation, submitOperation } from "./operations.js";
14
+ const help = `Usage: artifacts COMMAND [OPTIONS]
15
+
16
+ Global options:
17
+ --server RESOURCE Canonical OAuth resource, or ARTIFACTS_SERVER
18
+ --state-dir DIR Private local state, or ARTIFACTS_STATE_DIR
19
+ Default: ~/.local/state/artifacts
20
+ --help Print this reference
21
+ --version Print the installed package version
22
+
23
+ Commands:
24
+ login [--no-browser]
25
+ logout
26
+ whoami
27
+ create --snapshot FILE [--request-id ID] [--response full|compact]
28
+ create --command FILE
29
+ list [--limit N] [--cursor CURSOR]
30
+ read ID [--revision REV] [--response full|compact] [--out FILE]
31
+ update ID --snapshot FILE --expected-revision REV [--request-id ID]
32
+ [--response full|compact]
33
+ update [ID] --command FILE
34
+ edit ID --changes FILE [--request-id ID]
35
+ history ID [--limit N] [--before-sequence N] [--summary]
36
+ publish ID --revision REV --expected-version N [--request-id ID]
37
+ publish [ID] --command FILE
38
+ export ID --revision REV --out FILE
39
+ retry REQUEST_ID
40
+
41
+ Mutation command files contain the complete create, update, or publish JSON.
42
+ Mutation request IDs default to a random UUID. Commands are retained before
43
+ submission. Retry sends the exact retained command for the same server and owner.
44
+ Read --out saves the complete artifact JSON. Export --out saves the ZIP.
45
+ Output files are created exclusively and never overwrite an existing path.
46
+ JSON results go to stdout. Diagnostics and recovery guidance go to stderr.
47
+ ARTIFACTS_ACCESS_TOKEN supplies a token for automation without local login.
48
+ `;
49
+ class UsageError extends Error {
50
+ }
51
+ const commandOptions = {
52
+ login: ['no-browser'], logout: [], whoami: [],
53
+ create: ['snapshot', 'request-id', 'response', 'command'],
54
+ list: ['limit', 'cursor'], read: ['revision', 'response', 'out'],
55
+ update: ['snapshot', 'expected-revision', 'request-id', 'response', 'command'],
56
+ edit: ['changes', 'request-id'], history: ['limit', 'before-sequence', 'summary'],
57
+ publish: ['revision', 'expected-version', 'request-id', 'command'],
58
+ export: ['revision', 'out'], retry: [],
59
+ };
60
+ async function jsonFile(path) {
61
+ const file = await open(path, constants.O_RDONLY);
62
+ try {
63
+ const stat = await file.stat();
64
+ if (!stat.isFile() || stat.size > 8 * 1024 * 1024)
65
+ throw new UsageError('JSON input must be a regular file no larger than 8 MiB');
66
+ return JSON.parse(await file.readFile('utf8'));
67
+ }
68
+ finally {
69
+ await file.close();
70
+ }
71
+ }
72
+ async function writeOutput(path, bytes) {
73
+ const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
74
+ try {
75
+ await file.writeFile(bytes);
76
+ await file.sync();
77
+ }
78
+ finally {
79
+ await file.close();
80
+ }
81
+ }
82
+ async function main() {
83
+ const { values, positionals, tokens } = parseArgs({
84
+ allowPositionals: true, tokens: true,
85
+ options: {
86
+ server: { type: 'string' }, 'state-dir': { type: 'string' }, help: { type: 'boolean' }, version: { type: 'boolean' },
87
+ 'no-browser': { type: 'boolean' }, command: { type: 'string' }, snapshot: { type: 'string' },
88
+ changes: { type: 'string' }, 'request-id': { type: 'string' }, response: { type: 'string' },
89
+ revision: { type: 'string' }, 'expected-revision': { type: 'string' }, 'expected-version': { type: 'string' },
90
+ limit: { type: 'string' }, cursor: { type: 'string' }, 'before-sequence': { type: 'string' },
91
+ summary: { type: 'boolean' }, out: { type: 'string' },
92
+ },
93
+ });
94
+ const seen = new Set();
95
+ for (const token of tokens) {
96
+ if (token.kind !== 'option')
97
+ continue;
98
+ if (seen.has(token.name))
99
+ throw new UsageError(`Option --${token.name} cannot be repeated`);
100
+ seen.add(token.name);
101
+ }
102
+ if (values.version) {
103
+ const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
104
+ process.stdout.write(`${manifest.version}\n`);
105
+ return;
106
+ }
107
+ if (values.help) {
108
+ process.stdout.write(help);
109
+ return;
110
+ }
111
+ const [command, ...arguments_] = positionals;
112
+ if (!command || !Object.hasOwn(commandOptions, command))
113
+ throw new UsageError('Specify a command. Use artifacts --help for the reference.');
114
+ for (const option of seen)
115
+ if (!['server', 'state-dir', 'help', ...commandOptions[command]].includes(option))
116
+ throw new UsageError(`Option --${option} is not supported by ${command}`);
117
+ const resourceValue = values.server ?? process.env.ARTIFACTS_SERVER;
118
+ if (!resourceValue)
119
+ throw new UsageError('Specify --server RESOURCE or ARTIFACTS_SERVER. No production server is selected by default.');
120
+ const resource = canonicalResource(resourceValue);
121
+ const stateDir = resolve(values['state-dir'] ?? process.env.ARTIFACTS_STATE_DIR ?? join(homedir(), '.local', 'state', 'artifacts'));
122
+ function argument(required = true) {
123
+ if (arguments_.length > 1 || (required && arguments_.length !== 1))
124
+ throw new UsageError(`${command} requires ${required ? 'one' : 'zero or one'} positional argument`);
125
+ return arguments_[0];
126
+ }
127
+ function none() {
128
+ if (arguments_.length)
129
+ throw new UsageError(`${command} does not accept positional arguments`);
130
+ }
131
+ function required(name) {
132
+ const value = values[name];
133
+ if (typeof value !== 'string' || !value)
134
+ throw new UsageError(`Specify --${name}`);
135
+ return value;
136
+ }
137
+ function integer(name) {
138
+ const value = values[name];
139
+ if (value === undefined)
140
+ return undefined;
141
+ if (typeof value !== 'string' || !/^(0|[1-9]\d*)$/.test(value) || !Number.isSafeInteger(Number(value)))
142
+ throw new UsageError(`--${name} requires a nonnegative safe integer`);
143
+ return Number(value);
144
+ }
145
+ const response = values.response;
146
+ if (response !== undefined && response !== 'full' && response !== 'compact')
147
+ throw new UsageError('--response requires full or compact');
148
+ let commandToken;
149
+ const client = createArtifactHttpClient({ resource, accessToken: () => commandToken ??= accessToken({ resource, stateDir }) });
150
+ async function mutate(mutation) {
151
+ const owner = await client.identity();
152
+ const operation = await retainOperation(stateDir, resource, owner.userId, mutation);
153
+ process.stderr.write(`Retained operation ${operation.command.requestId}. Recover with artifacts retry ${operation.command.requestId} using the same server and state directory.\n`);
154
+ return submitOperation(client, operation);
155
+ }
156
+ async function commandFile(kind) {
157
+ const extra = [...seen].filter((option) => !['server', 'state-dir', 'command'].includes(option));
158
+ if (extra.length)
159
+ throw new UsageError('--command cannot be combined with mutation options');
160
+ const value = await jsonFile(required('command'));
161
+ if (kind === 'create') {
162
+ none();
163
+ return { kind, command: createSchema.parse(value) };
164
+ }
165
+ const mutation = kind === 'update' ? { kind, command: updateSchema.parse(value) } : { kind, command: publishSchema.parse(value) };
166
+ const id = argument(false);
167
+ if (id !== undefined && id !== mutation.command.artifactId)
168
+ throw new UsageError('The positional artifact ID must match the command file');
169
+ return mutation;
170
+ }
171
+ switch (command) {
172
+ case 'login':
173
+ none();
174
+ return login({ resource, stateDir, noBrowser: values['no-browser'] ?? false, onAuthorizationUrl: (url) => { process.stderr.write(`Authorize this login in your browser:\n${url}\n`); } });
175
+ case 'logout':
176
+ none();
177
+ return logout({ resource, stateDir });
178
+ case 'whoami':
179
+ none();
180
+ return client.identity();
181
+ case 'create': {
182
+ if (values.command)
183
+ return mutate(await commandFile('create'));
184
+ none();
185
+ return mutate({ kind: 'create', command: createSchema.parse({ requestId: values['request-id'] ?? randomUUID(), snapshot: await jsonFile(required('snapshot')), ...(response === undefined ? {} : { response }) }) });
186
+ }
187
+ case 'list':
188
+ none();
189
+ return client.list({ ...(values.limit === undefined ? {} : { limit: integer('limit') }), ...(values.cursor === undefined ? {} : { cursor: values.cursor }) });
190
+ case 'read': {
191
+ const result = await client.read({ artifactId: argument(), ...(values.revision === undefined ? {} : { revisionId: values.revision }), ...(response === undefined ? {} : { response }) });
192
+ if (values.out)
193
+ await writeOutput(values.out, `${JSON.stringify(result, null, 2)}\n`);
194
+ return result;
195
+ }
196
+ case 'update': {
197
+ if (values.command)
198
+ return mutate(await commandFile('update'));
199
+ return mutate({ kind: 'update', command: updateSchema.parse({ artifactId: argument(), requestId: values['request-id'] ?? randomUUID(), expectedRevisionId: required('expected-revision'), snapshot: await jsonFile(required('snapshot')), ...(response === undefined ? {} : { response }) }) });
200
+ }
201
+ case 'edit': {
202
+ const artifactId = argument();
203
+ const changes = await jsonFile(required('changes'));
204
+ const input = await prepareArtifactEdit(client, { artifactId, requestId: values['request-id'] ?? randomUUID(), changes: changes });
205
+ return mutate({ kind: 'update', command: updateSchema.parse(input) });
206
+ }
207
+ case 'history': return client.history({ artifactId: argument(), ...(values.limit === undefined ? {} : { limit: integer('limit') }), ...(values['before-sequence'] === undefined ? {} : { beforeSequence: integer('before-sequence') }), ...(values.summary ? { response: 'summary' } : {}) });
208
+ case 'publish': {
209
+ if (values.command)
210
+ return mutate(await commandFile('publish'));
211
+ required('expected-version');
212
+ return mutate({ kind: 'publish', command: publishSchema.parse({ artifactId: argument(), requestId: values['request-id'] ?? randomUUID(), revisionId: required('revision'), expectedPublicationVersion: integer('expected-version') }) });
213
+ }
214
+ case 'export': {
215
+ const output = required('out');
216
+ const archive = await client.export({ artifactId: argument(), revisionId: required('revision') });
217
+ await writeOutput(output, archive.bytes);
218
+ return { filename: archive.filename, path: resolve(output), mediaType: archive.mediaType, sha256: archive.sha256, bytes: archive.bytes.byteLength };
219
+ }
220
+ case 'retry': {
221
+ const requestId = argument();
222
+ const owner = await client.identity();
223
+ const operation = await loadOperation(stateDir, requestId, resource, owner.userId);
224
+ process.stderr.write(`Replaying retained ${operation.kind} operation ${requestId}. The result is its retained receipt and may differ from current state.\n`);
225
+ return submitOperation(client, operation);
226
+ }
227
+ }
228
+ }
229
+ try {
230
+ const result = await main();
231
+ if (result !== undefined)
232
+ process.stdout.write(`${JSON.stringify(result)}\n`);
233
+ }
234
+ catch (error) {
235
+ const body = error instanceof HttpError
236
+ ? { code: error.code, message: error.message, status: error.status, details: error.details }
237
+ : error instanceof z.ZodError
238
+ ? { code: 'invalid_input', message: 'Input does not match the artifact command schema', details: { issues: error.issues } }
239
+ : { code: error instanceof UsageError || error instanceof TypeError || error instanceof SyntaxError ? 'invalid_arguments' : 'local_error', message: error instanceof Error ? error.message : 'Command failed', details: {} };
240
+ process.stderr.write(`${JSON.stringify({ error: body })}\n`);
241
+ process.exitCode = 1;
242
+ }
@@ -0,0 +1,144 @@
1
+ import { z } from 'zod';
2
+ import { createSchema, publishSchema, updateSchema } from '@yargote/artifacts-contracts';
3
+ import type { ArtifactHttpClient } from '@yargote/artifacts-client';
4
+ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
5
+ kind: z.ZodLiteral<"create">;
6
+ command: z.ZodObject<{
7
+ requestId: z.ZodString;
8
+ snapshot: z.ZodObject<{
9
+ metadata: z.ZodObject<{
10
+ title: z.ZodString;
11
+ description: z.ZodString;
12
+ custom: z.ZodOptional<z.ZodJSONSchema>;
13
+ }, z.core.$strict>;
14
+ content: z.ZodObject<{
15
+ kind: z.ZodEnum<{
16
+ text: "text";
17
+ html: "html";
18
+ markdown: "markdown";
19
+ }>;
20
+ text: z.ZodString;
21
+ }, z.core.$strict>;
22
+ files: z.ZodArray<z.ZodObject<{
23
+ path: z.ZodString;
24
+ mediaType: z.ZodString;
25
+ encoding: z.ZodEnum<{
26
+ utf8: "utf8";
27
+ base64: "base64";
28
+ }>;
29
+ data: z.ZodString;
30
+ }, z.core.$strict>>;
31
+ }, z.core.$strict>;
32
+ response: z.ZodOptional<z.ZodEnum<{
33
+ full: "full";
34
+ compact: "compact";
35
+ }>>;
36
+ }, z.core.$strict>;
37
+ version: z.ZodLiteral<1>;
38
+ resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
39
+ ownerId: z.ZodString;
40
+ }, z.core.$strict>, z.ZodObject<{
41
+ kind: z.ZodLiteral<"update">;
42
+ command: z.ZodObject<{
43
+ requestId: z.ZodString;
44
+ artifactId: z.ZodString;
45
+ expectedRevisionId: z.ZodString;
46
+ snapshot: z.ZodObject<{
47
+ metadata: z.ZodObject<{
48
+ title: z.ZodString;
49
+ description: z.ZodString;
50
+ custom: z.ZodOptional<z.ZodJSONSchema>;
51
+ }, z.core.$strict>;
52
+ content: z.ZodObject<{
53
+ kind: z.ZodEnum<{
54
+ text: "text";
55
+ html: "html";
56
+ markdown: "markdown";
57
+ }>;
58
+ text: z.ZodString;
59
+ }, z.core.$strict>;
60
+ files: z.ZodArray<z.ZodObject<{
61
+ path: z.ZodString;
62
+ mediaType: z.ZodString;
63
+ encoding: z.ZodEnum<{
64
+ utf8: "utf8";
65
+ base64: "base64";
66
+ }>;
67
+ data: z.ZodString;
68
+ }, z.core.$strict>>;
69
+ }, z.core.$strict>;
70
+ response: z.ZodOptional<z.ZodEnum<{
71
+ full: "full";
72
+ compact: "compact";
73
+ }>>;
74
+ }, z.core.$strict>;
75
+ version: z.ZodLiteral<1>;
76
+ resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
77
+ ownerId: z.ZodString;
78
+ }, z.core.$strict>, z.ZodObject<{
79
+ kind: z.ZodLiteral<"publish">;
80
+ command: z.ZodObject<{
81
+ requestId: z.ZodString;
82
+ artifactId: z.ZodString;
83
+ revisionId: z.ZodString;
84
+ expectedPublicationVersion: z.ZodNumber;
85
+ }, z.core.$strict>;
86
+ version: z.ZodLiteral<1>;
87
+ resource: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
88
+ ownerId: z.ZodString;
89
+ }, z.core.$strict>], "kind">;
90
+ export type PreparedOperation = z.infer<typeof operationSchema>;
91
+ export type Mutation = {
92
+ kind: 'create';
93
+ command: z.input<typeof createSchema>;
94
+ } | {
95
+ kind: 'update';
96
+ command: z.input<typeof updateSchema>;
97
+ } | {
98
+ kind: 'publish';
99
+ command: z.input<typeof publishSchema>;
100
+ };
101
+ export declare function operationPath(stateDir: string, resource: string, requestId: string): string;
102
+ export declare function retainOperation(stateDir: string, resource: string, ownerId: string, mutation: Mutation): Promise<PreparedOperation>;
103
+ export declare function loadOperation(stateDir: string, requestId: string, resource: string, ownerId: string): Promise<PreparedOperation>;
104
+ export declare function submitOperation(client: ArtifactHttpClient, operation: PreparedOperation): Promise<{
105
+ revisionId: string;
106
+ version: number;
107
+ url: string;
108
+ artifactId: string;
109
+ }> | Promise<{
110
+ artifactId: string;
111
+ ownerId: string;
112
+ headRevisionId: string;
113
+ publication: {
114
+ revisionId: string;
115
+ version: number;
116
+ url: string;
117
+ } | null;
118
+ revision: {
119
+ id: string;
120
+ parentRevisionId: string | null;
121
+ sequence: number;
122
+ authorUserId: string;
123
+ authorClientId: string;
124
+ createdAt: string;
125
+ sha256: string;
126
+ snapshot: {
127
+ metadata: {
128
+ title: string;
129
+ description: string;
130
+ custom?: z.core.util.JSONType | undefined;
131
+ };
132
+ content: {
133
+ kind: "text" | "html" | "markdown";
134
+ text: string;
135
+ };
136
+ files: {
137
+ path: string;
138
+ mediaType: string;
139
+ encoding: "utf8" | "base64";
140
+ data: string;
141
+ }[];
142
+ };
143
+ };
144
+ } | import("@yargote/artifacts-client").CompactArtifactRead>;
@@ -0,0 +1,90 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, mkdir, open } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import { join } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { createSchema, publishSchema, updateSchema } from '@yargote/artifacts-contracts';
7
+ import { canonicalResource } from '@yargote/artifacts-contracts/http';
8
+ const binding = {
9
+ version: z.literal(1),
10
+ resource: z.string().transform(canonicalResource),
11
+ ownerId: z.string().min(1).max(128),
12
+ };
13
+ export const operationSchema = z.discriminatedUnion('kind', [
14
+ z.strictObject({ ...binding, kind: z.literal('create'), command: createSchema }),
15
+ z.strictObject({ ...binding, kind: z.literal('update'), command: updateSchema }),
16
+ z.strictObject({ ...binding, kind: z.literal('publish'), command: publishSchema }),
17
+ ]);
18
+ export function operationPath(stateDir, resource, requestId) {
19
+ createSchema.shape.requestId.parse(requestId);
20
+ return join(stateDir, 'operations', `${createHash('sha256').update(JSON.stringify([canonicalResource(resource), requestId])).digest('hex')}.json`);
21
+ }
22
+ async function privateDirectory(path) {
23
+ await mkdir(path, { recursive: true, mode: 0o700 });
24
+ const stat = await lstat(path);
25
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || (process.platform !== 'win32' && (stat.mode & 0o077) !== 0))
26
+ throw new Error(`The state directory must be private and owned by the current user: ${path}`);
27
+ }
28
+ export async function retainOperation(stateDir, resource, ownerId, mutation) {
29
+ const operation = operationSchema.parse({ version: 1, resource, ownerId, ...mutation });
30
+ await privateDirectory(stateDir);
31
+ const directory = join(stateDir, 'operations');
32
+ await privateDirectory(directory);
33
+ const path = operationPath(stateDir, resource, operation.command.requestId);
34
+ let file;
35
+ try {
36
+ file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
37
+ }
38
+ catch (error) {
39
+ if (error.code === 'EEXIST')
40
+ throw new Error(`An operation already exists for ${operation.command.requestId}. Use artifacts retry ${operation.command.requestId} to send the retained command.`);
41
+ throw error;
42
+ }
43
+ try {
44
+ await file.writeFile(`${JSON.stringify(operation)}\n`, 'utf8');
45
+ await file.sync();
46
+ }
47
+ finally {
48
+ await file.close();
49
+ }
50
+ if (process.platform !== 'win32') {
51
+ const handle = await open(directory, constants.O_RDONLY);
52
+ try {
53
+ await handle.sync();
54
+ }
55
+ finally {
56
+ await handle.close();
57
+ }
58
+ }
59
+ return operation;
60
+ }
61
+ export async function loadOperation(stateDir, requestId, resource, ownerId) {
62
+ await privateDirectory(stateDir);
63
+ await privateDirectory(join(stateDir, 'operations'));
64
+ const file = await open(operationPath(stateDir, resource, requestId), constants.O_RDONLY | constants.O_NOFOLLOW);
65
+ let bytes;
66
+ try {
67
+ const stat = await file.stat();
68
+ if (!stat.isFile() || stat.size > 8 * 1024 * 1024 || (process.getuid && stat.uid !== process.getuid()) || (process.platform !== 'win32' && (stat.mode & 0o077) !== 0))
69
+ throw new Error('The retained operation must be a private regular file no larger than 8 MiB');
70
+ bytes = await file.readFile();
71
+ }
72
+ finally {
73
+ await file.close();
74
+ }
75
+ const operation = operationSchema.parse(JSON.parse(bytes.toString('utf8')));
76
+ if (operation.command.requestId !== requestId)
77
+ throw new Error('The retained request ID does not match the requested operation');
78
+ if (operation.resource !== canonicalResource(resource))
79
+ throw new Error('The retained operation belongs to a different server resource');
80
+ if (operation.ownerId !== ownerId)
81
+ throw new Error('The retained operation belongs to a different owner');
82
+ return operation;
83
+ }
84
+ export function submitOperation(client, operation) {
85
+ switch (operation.kind) {
86
+ case 'create': return client.create(operation.command);
87
+ case 'update': return client.update(operation.command);
88
+ case 'publish': return client.publish(operation.command);
89
+ }
90
+ }