Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions skills/xapi-provider/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ scope. Do not try unrelated keys.
- Before any create or publish: apply the
[validation checklist](reference/validation-checklist.md).

For a local service LOGO, use `provider create --file ./service.json --logo-file
./logo.png` after checking command help. Read the local LOGO section in
[lifecycle management](reference/lifecycle-management.md) for limits, scope,
conflict rules and upload failure handling.

## Standard lifecycle

Prepare `service.json`, then create and inspect the draft:
Expand Down
30 changes: 30 additions & 0 deletions skills/xapi-provider/reference/lifecycle-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,36 @@ npx xapi-to provider create --file ./service.json
The output contains the service and initial version/revision IDs. Keep those
IDs for later commands.

### Upload a local LOGO when creating

```bash
npx xapi-to provider create --file ./service.json --logo-file ./logo.png
```

`--logo-file` accepts JPG, PNG, GIF and WebP up to 10 MB. The CLI checks the
local file, uploads it using the same API Key with `service:create`, then sends
the returned remote URL as `logoUrl` in the create request. Storage credentials
remain on the server. Existing JSON `logoUrl` and creation without a LOGO work
as before. Remove a JSON `logoUrl` before passing `--logo-file`; supplying both
is an error. This flag is supported only by `provider create`.

File, authentication, permission, network and storage errors stop creation and
return a nonzero exit code. Uploads are not automatically retried. Upload and
service creation are separate writes: if creation fails after upload, the image
may remain unused. Inspect `provider list` after an ambiguous create response
before retrying; do not automatically replay the create write.

The installed CLI help must list `--logo-file`, and the selected server must
support provider LOGO uploads. A 404 requires a server update; 401 requires
checking the configured API Key; 403 requires `service:create` and allowed
source IPs. The server rechecks image content and enforces a 20/minute/IP limit.
A repository change does not imply that the CLI package or backend has already
been released.

After successful creation, run `provider get <service-id>` and verify its
`logoUrl` matches the returned image URL; verify the image in the web console
when validating a deployed environment.

## Service metadata versus version contract

`provider update` changes marketplace metadata, the linked Skill, or service
Expand Down
30 changes: 30 additions & 0 deletions skills/xapi/guides/provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,36 @@ npx xapi-to provider list
npx xapi-to provider get <service-id>
```

### Upload a local LOGO when creating

```bash
npx xapi-to provider create --file ./service.json --logo-file ./logo.png
```

`--logo-file` accepts JPG, PNG, GIF and WebP up to 10 MB. The CLI checks the
local file, uploads it using the same API Key with `service:create`, then sends
the returned remote URL as `logoUrl` in the create request. Storage credentials
remain on the server. Existing JSON `logoUrl` and creation without a LOGO work
as before. Remove a JSON `logoUrl` before passing `--logo-file`; supplying both
is an error. This flag is supported only by `provider create`.

File, authentication, permission, network and storage errors stop creation and
return a nonzero exit code. Uploads are not automatically retried. Upload and
service creation are separate writes: if creation fails after upload, the image
may remain unused. Inspect `provider list` after an ambiguous create response
before retrying; do not automatically replay the create write.

The installed CLI help must list `--logo-file`, and the selected server must
support provider LOGO uploads. A 404 requires a server update; 401 requires
checking the configured API Key; 403 requires `service:create` and allowed
source IPs. The server rechecks image content and enforces a 20/minute/IP limit.
A repository change does not imply that the CLI package or backend has already
been released.

After successful creation, run `provider get <service-id>` and verify its
`logoUrl` matches the returned image URL; verify the image in the web console
when validating a deployed environment.

Keep the three service content layers distinct:

- `description` is the short marketplace-card summary.
Expand Down
17 changes: 16 additions & 1 deletion src/commands/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mkdir, open, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { apiKeyApiRequest, HttpError } from '../client.ts';
import { providerOnboarding, PROVIDER_ONBOARDING_HELP } from './provider-onboarding.ts';
import { uploadProviderLogo } from '../provider-logo.ts';
import { redactProvider } from '../provider-client.ts';
import {
getConfig,
Expand All @@ -25,7 +26,7 @@ USAGE
xapi-to provider wait <service-id> --revision <id> [--interval 2s] [--timeout 10m]
xapi-to provider list
xapi-to provider get <service-id> [--version <version>]
xapi-to provider create --file <service.json> [rate-limit flags]
xapi-to provider create --file <service.json> [--logo-file <path>] [rate-limit flags]
xapi-to provider update <service-id> [metadata/rate-limit flags]
xapi-to provider versions <service-id>
xapi-to provider version update <service-id> <version-id> --file <contract.json> [--replace]
Expand Down Expand Up @@ -59,6 +60,10 @@ METADATA FLAGS
--logo-url <url> Service logo URL
--category <category> Marketplace category

CREATE LOGO
--logo-file <path> Upload a local JPG, PNG, GIF or WebP (up to 10 MB)
Requires service:create; conflicts with JSON logoUrl

SERVICE RATE-LIMIT FLAGS
--rate-limit-requests <count> Allowed requests per period (1-1000000)
--rate-limit-period-seconds <seconds> Period length in seconds (1-86400)
Expand Down Expand Up @@ -229,6 +234,9 @@ export async function provider(args: string[], flags: Record<string, string>) {
}
const onboardingCommand = ['spec-rules', 'import', 'submit', 'wait'].includes(args[0]);
const revisionAlias = ['update', 'review'].includes(args[0]) && flags.revision !== undefined;
if (flags['logo-file'] !== undefined && args[0] !== 'create') {
err('--logo-file is only supported by provider create');
}
if (onboardingCommand || revisionAlias) return providerOnboarding(args, flags);
const cfg = getConfig();
requireApiKey(cfg);
Expand All @@ -254,6 +262,13 @@ export async function provider(args: string[], flags: Record<string, string>) {
case 'create': {
const body = await readJsonObject(required(flags.file, 'xapi-to provider create --file <service.json>'));
applyRateLimitFlags(body, flags);
if (flags['logo-file'] !== undefined) {
const logoFile = requiredFlag(flags['logo-file'], 'xapi-to provider create --file <service.json> --logo-file <path>');
if ((body.logoUrl !== undefined && body.logoUrl !== null && body.logoUrl !== '') || flags['logo-url'] !== undefined) {
err('--logo-file conflicts with logoUrl or --logo-url', 'Use either a local LOGO file or an existing URL.');
}
body.logoUrl = await uploadProviderLogo(XAPI_API_HOST, apiKey, logoFile);
}
result = await apiKeyApiRequest(XAPI_API_HOST, apiKey, `${BASE}/services`, {
method: 'POST',
body,
Expand Down
76 changes: 76 additions & 0 deletions src/provider-logo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { open } from 'node:fs/promises';
import { basename } from 'node:path';
import { HttpError, uploadRequest } from './client.ts';
import { scheme } from './config.ts';

const MAX_LOGO_SIZE = 10 * 1024 * 1024;

function imageMime(bytes: Buffer): string | undefined {
if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'image/png';
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg';
if (['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString('ascii'))) return 'image/gif';
if (bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
}

async function readLogo(path: string): Promise<{ bytes: Buffer; mime: string }> {
let handle;
try {
handle = await open(path, 'r');
const stat = await handle.stat();
if (!stat.isFile()) throw new Error('LOGO must be a regular image file.');
if (stat.size > MAX_LOGO_SIZE) throw new Error('LOGO exceeds the 10 MB limit.');
// Bound the read even if the file grows after stat().
const bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_LOGO_SIZE + 1));
let offset = 0;
while (offset < bytes.length) {
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, null);
if (!bytesRead) break;
offset += bytesRead;
}
if (offset > MAX_LOGO_SIZE || offset > stat.size) throw new Error('LOGO changed while being read; retry with a stable file.');
const content = bytes.subarray(0, offset);
const mime = imageMime(content);
if (!mime) throw new Error('Unsupported LOGO format. Use JPG, PNG, GIF or WebP.');
return { bytes: content, mime };
} catch (error: any) {
if (error?.code === 'ENOENT') throw new Error('LOGO file does not exist. Check --logo-file.');
if (error?.code === 'EACCES' || error?.code === 'EPERM') throw new Error('LOGO file is not readable. Check file permissions.');
if (error?.code) throw new Error('Cannot read LOGO file. Check --logo-file and file permissions.');
throw error;
} finally {
await handle?.close();
}
}

/** Upload with the platform API Key; storage credentials stay on the server. */
export async function uploadProviderLogo(apiHost: string, apiKey: string, path: string): Promise<string> {
const { bytes, mime } = await readLogo(path);
const form = new FormData();
form.set('file', new Blob([new Uint8Array(bytes)], { type: mime }), basename(path));
let response: any;
try {
response = await uploadRequest(`${scheme(apiHost)}://${apiHost}/api/api-services/agent/logo`, {
method: 'POST', headers: { 'XAPI-KEY': apiKey }, body: form,
});
} catch (error) {
if (error instanceof HttpError) {
const hint = error.status === 401 ? 'Check your API Key.'
: error.status === 403 ? 'Grant service:create and check API Key IP restrictions.'
: error.status === 404 ? 'The server must support provider LOGO uploads.'
: error.status === 413 ? 'Use an image up to 10 MB.'
: 'Check image format and server image storage configuration.';
throw new Error(`LOGO upload failed (HTTP ${error.status}). ${hint} Service was not created.`);
}
// Avoid exposing a response body, URL or credentials from a transport error.
throw new Error('LOGO upload failed. Check network access and server image storage, then retry. Service was not created.');
}
const url = response?.url ?? response?.data?.url;
try {
if (typeof url !== 'string') throw new Error();
const parsed = new URL(url);
if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password) throw new Error();
} catch {
throw new Error('LOGO upload returned no valid image URL. Service was not created.');
}
return url;
}
75 changes: 75 additions & 0 deletions src/tests/provider-logo-http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
import { mkdtemp, rm, writeFile, chmod } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jA7sAAAAASUVORK5CYII=', 'base64');

describe('provider LOGO CLI over HTTP', () => {
let dir: string;
let server: ReturnType<typeof Bun.serve>;
let uploadStatus = 201;
let service: any;
let requests: string[];
let uploaded: Uint8Array;
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), 'xapi logo cli '));
await writeFile(join(dir, 'service.json'), JSON.stringify({ name: 'Logo test', authType: 'HEADER' }));
await writeFile(join(dir, 'logo image.png'), PNG);
server = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(req) {
const path = new URL(req.url).pathname;
requests.push(`${req.method} ${path}`);
expect(req.headers.get('xapi-key')).toBe('local-test-key');
if (path.endsWith('/logo')) {
const form = await req.formData();
const file = form.get('file') as File;
expect(file.type).toBe('image/png');
expect(file.name).toBe('logo image.png');
uploaded = new Uint8Array(await file.arrayBuffer());
return Response.json({ url: 'https://cdn.example.com/logos/local.png' }, { status: uploadStatus });
}
if (req.method === 'POST') service = { ...await req.json(), id: 'service-1' };
return Response.json(service);
} });
});
afterAll(async () => { server?.stop(true); await rm(dir, { recursive: true, force: true }); });
async function cli(...args: string[]) {
const child = Bun.spawn([process.execPath, fileURLToPath(new URL('../index.ts', import.meta.url)), 'provider', ...args], {
env: { ...process.env, XAPI_KEY: 'local-test-key', XAPI_API_HOST: `127.0.0.1:${server.port}` }, stdout: 'pipe', stderr: 'pipe',
});
const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
return { stdout, stderr, exit };
}
it('sends exact multipart bytes, creates with the uploaded URL, then reads the same logoUrl', async () => {
requests = []; uploadStatus = 201;
const created = await cli('create', '--file', join(dir, 'service.json'), '--logo-file', join(dir, 'logo image.png'));
expect(created.exit).toBe(0);
expect(JSON.parse(created.stdout).logoUrl).toBe('https://cdn.example.com/logos/local.png');
expect(Buffer.from(uploaded)).toEqual(PNG);
expect(requests).toEqual(['POST /api/api-services/agent/logo', 'POST /api/api-services/agent/services']);
const read = await cli('get', 'service-1');
expect(read.exit).toBe(0);
expect(JSON.parse(read.stdout).logoUrl).toBe(service.logoUrl);
});
it('exits nonzero on storage failure and never sends create or retries upload', async () => {
requests = []; uploadStatus = 500;
const result = await cli('create', '--file', join(dir, 'service.json'), '--logo-file', join(dir, 'logo image.png'));
expect(result.exit).not.toBe(0);
expect(result.stderr).toContain('LOGO upload failed (HTTP 500)');
expect(requests).toEqual(['POST /api/api-services/agent/logo']);
});
it('exits nonzero for an unreadable local file before contacting the API', async () => {
// Permission bits do not restrict root; the other validation tests still run there.
if (process.getuid?.() === 0) return;
requests = [];
const path = join(dir, 'private.png');
await writeFile(path, PNG); await chmod(path, 0);
try {
const result = await cli('create', '--file', join(dir, 'service.json'), '--logo-file', path);
expect(result.exit).not.toBe(0);
expect(result.stderr).toContain('not readable');
expect(requests).toEqual([]);
} finally { await chmod(path, 0o600); }
});
});
Loading
Loading