@yargote/artifacts-cli 0.1.0-beta.6 → 0.1.0-beta.8
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 +44 -13
- package/package.json +3 -3
- package/skills/artifacts-cli/SKILL.md +8 -4
- package/skills/artifacts-cli/references/commands.md +39 -6
- package/skills/artifacts-cli/references/verify-retained.md +119 -0
- package/skills/artifacts-cli/scripts/prepare-retained.mjs +149 -0
- package/skills/artifacts-design/SKILL.md +12 -2
- package/skills/artifacts-design/references/canvas.md +100 -0
- package/skills/artifacts-design/references/local-checks.md +70 -0
- package/skills/artifacts-design/scripts/embed-fonts.mjs +208 -7
- package/skills/artifacts-design/scripts/verify-local.mjs +333 -0
- 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 +48 -12
- package/src/operations.d.ts +3 -0
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
|
|
@@ -57,10 +61,23 @@ 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
62
|
exists while it contains an artifact. Move changes the project of an artifact
|
|
59
63
|
without creating a revision; --no-project removes it from its project.
|
|
64
|
+
A slug such as concepts/rag names an artifact within its project, or within
|
|
65
|
+
the unfiled artifacts without --project. Read --slug finds it without its ID.
|
|
60
66
|
Read --out saves the returned artifact JSON. Export --out saves the ZIP.
|
|
61
67
|
Read --quiet requires --out and suppresses successful JSON stdout.
|
|
62
68
|
Output files are created exclusively and never overwrite an existing path.
|
|
63
69
|
JSON results go to stdout. Diagnostics and recovery guidance go to stderr.
|
|
70
|
+
Login accepts an MCP URL when its metadata advertises artifacts_rest_resource.
|
|
71
|
+
Split MCP and REST resources require this relation. Login remembers the verified
|
|
72
|
+
REST URL for later commands. --server takes precedence over ARTIFACTS_SERVER,
|
|
73
|
+
which takes precedence over the saved URL. Ordinary overrides do not replace
|
|
74
|
+
the saved URL. Logout keeps it for a later login.
|
|
75
|
+
Login checks <resource>/v1/identity before browser
|
|
76
|
+
authorization. Legacy /mcp
|
|
77
|
+
resources work when their REST API is at /mcp/v1. If the check fails, confirm
|
|
78
|
+
the configured REST URL with your service administrator.
|
|
79
|
+
Login errors report the phase, method, endpoint, and HTTP status when received.
|
|
80
|
+
Network failures have no HTTP status. Login verifies identity again after OAuth.
|
|
64
81
|
ARTIFACTS_ACCESS_TOKEN supplies a token for automation without local login.
|
|
65
82
|
Skills commands work offline without a server, login, or private state.
|
|
66
83
|
They print plain text. Global server and state options are accepted and ignored.
|
|
@@ -73,8 +90,8 @@ class UsageError extends Error {
|
|
|
73
90
|
}
|
|
74
91
|
const commandOptions = {
|
|
75
92
|
login: ['no-browser'], logout: [], whoami: [],
|
|
76
|
-
create: ['snapshot', 'project', 'request-id', 'response', 'command'],
|
|
77
|
-
list: ['limit', 'cursor', 'project'], projects: [], read: ['revision', 'response', 'out', 'quiet'],
|
|
93
|
+
create: ['snapshot', 'project', 'slug', 'request-id', 'response', 'command'],
|
|
94
|
+
list: ['limit', 'cursor', 'project'], projects: [], read: ['slug', 'project', 'revision', 'response', 'out', 'quiet'],
|
|
78
95
|
update: ['snapshot', 'expected-revision', 'request-id', 'response', 'command'],
|
|
79
96
|
edit: ['changes', 'request-id'], history: ['limit', 'before-sequence', 'summary'],
|
|
80
97
|
publish: ['revision', 'expected-version', 'request-id', 'command'],
|
|
@@ -116,7 +133,7 @@ async function main() {
|
|
|
116
133
|
summary: { type: 'boolean' }, out: { type: 'string' }, quiet: { type: 'boolean' },
|
|
117
134
|
full: { type: 'boolean' }, all: { type: 'boolean' }, agent: { type: 'string' },
|
|
118
135
|
global: { type: 'boolean' }, force: { type: 'boolean' },
|
|
119
|
-
project: { type: 'string' }, 'no-project': { type: 'boolean' }, email: { type: 'string' }, notify: { type: 'boolean' },
|
|
136
|
+
project: { type: 'string' }, 'no-project': { type: 'boolean' }, slug: { type: 'string' }, email: { type: 'string' }, notify: { type: 'boolean' },
|
|
120
137
|
},
|
|
121
138
|
});
|
|
122
139
|
const seen = new Set();
|
|
@@ -144,11 +161,20 @@ async function main() {
|
|
|
144
161
|
throw new UsageError(`Option --${option} is not supported by ${command}`);
|
|
145
162
|
if (command === 'skills')
|
|
146
163
|
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
164
|
const stateDir = resolve(values['state-dir'] ?? process.env.ARTIFACTS_STATE_DIR ?? join(homedir(), '.local', 'state', 'artifacts'));
|
|
165
|
+
let resourceValue = values.server ?? process.env.ARTIFACTS_SERVER;
|
|
166
|
+
if (resourceValue === undefined) {
|
|
167
|
+
const store = await CredentialStore.open(stateDir);
|
|
168
|
+
try {
|
|
169
|
+
resourceValue = await store.exclusive(() => store.readSelectedResource());
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
store.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (resourceValue === undefined)
|
|
176
|
+
throw new UsageError('No server is saved. Run artifacts login --server RESOURCE or set ARTIFACTS_SERVER.');
|
|
177
|
+
const resource = canonicalResource(resourceValue);
|
|
152
178
|
function argument(required = true) {
|
|
153
179
|
if (arguments_.length > 1 || (required && arguments_.length !== 1))
|
|
154
180
|
throw new UsageError(`${command} requires ${required ? 'one' : 'zero or one'} positional argument`);
|
|
@@ -204,7 +230,12 @@ async function main() {
|
|
|
204
230
|
switch (command) {
|
|
205
231
|
case 'login':
|
|
206
232
|
none();
|
|
207
|
-
|
|
233
|
+
{
|
|
234
|
+
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`); } });
|
|
235
|
+
if (result.resource !== resource)
|
|
236
|
+
process.stderr.write(`REST resource: ${result.resource}\nThis verified REST URL is saved for subsequent commands.\n`);
|
|
237
|
+
return result.identity;
|
|
238
|
+
}
|
|
208
239
|
case 'logout':
|
|
209
240
|
none();
|
|
210
241
|
return logout({ resource, stateDir });
|
|
@@ -215,7 +246,7 @@ async function main() {
|
|
|
215
246
|
if (values.command)
|
|
216
247
|
return mutate(await commandFile('create'));
|
|
217
248
|
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 }) }) });
|
|
249
|
+
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
250
|
}
|
|
220
251
|
case 'list':
|
|
221
252
|
none();
|
|
@@ -224,7 +255,12 @@ async function main() {
|
|
|
224
255
|
none();
|
|
225
256
|
return client.projects();
|
|
226
257
|
case 'read': {
|
|
227
|
-
|
|
258
|
+
if (values.slug === undefined && values.project !== undefined)
|
|
259
|
+
throw new UsageError('read --project requires --slug');
|
|
260
|
+
if ((values.slug === undefined) !== (arguments_.length === 1) || arguments_.length > 1)
|
|
261
|
+
throw new UsageError('read requires exactly one of an artifact ID or --slug SLUG');
|
|
262
|
+
const target = values.slug === undefined ? { artifactId: arguments_[0] } : { slug: values.slug, ...(values.project === undefined ? {} : { project: values.project }) };
|
|
263
|
+
const result = await client.read({ ...target, ...(values.revision === undefined ? {} : { revisionId: values.revision }), ...(response === undefined ? {} : { response }) });
|
|
228
264
|
if (values.out)
|
|
229
265
|
await writeOutput(values.out, `${JSON.stringify(result, null, 2)}\n`);
|
|
230
266
|
return quiet ? undefined : result;
|
package/src/operations.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
16
16
|
text: "text";
|
|
17
17
|
html: "html";
|
|
18
18
|
markdown: "markdown";
|
|
19
|
+
design: "design";
|
|
19
20
|
}>;
|
|
20
21
|
text: z.ZodString;
|
|
21
22
|
}, z.core.$strict>;
|
|
@@ -30,6 +31,7 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
30
31
|
}, z.core.$strict>>;
|
|
31
32
|
}, z.core.$strict>;
|
|
32
33
|
project: z.ZodOptional<z.ZodString>;
|
|
34
|
+
slug: z.ZodOptional<z.ZodString>;
|
|
33
35
|
response: z.ZodOptional<z.ZodEnum<{
|
|
34
36
|
full: "full";
|
|
35
37
|
compact: "compact";
|
|
@@ -55,6 +57,7 @@ export declare const operationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
55
57
|
text: "text";
|
|
56
58
|
html: "html";
|
|
57
59
|
markdown: "markdown";
|
|
60
|
+
design: "design";
|
|
58
61
|
}>;
|
|
59
62
|
text: z.ZodString;
|
|
60
63
|
}, z.core.$strict>;
|