@yuchaocheng/yusijia 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/dist/core.js ADDED
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
4
+ export const LIMITS = { maxRequestBytes: 2097152, maxCandidates: 200, maxActions: 500, maxFileBytes: 15728640 };
5
+ export const DEFAULT_SITE = 'https://yusijia.me';
6
+ const messages = {
7
+ INVALID_INPUT: 'Invalid input. Check the command and typed plan.',
8
+ PAYLOAD_TOO_LARGE: 'Request exceeds 2 MiB. Simplify or split into a newly confirmed plan.',
9
+ LOCAL_STATE_TOO_LARGE: 'Private file exceeds 32 MiB. Resolve pending operations before adding more; no oversized file was written.',
10
+ AUTH_REQUIRED: 'Run auth login for this site.', INVALID_CREDENTIAL: 'Credential is invalid or revoked. Log in again.',
11
+ CREDENTIAL_EXPIRED: 'Credential expired. Log in again.', ACCOUNT_NOT_ALLOWED: 'Account is not allowed.',
12
+ FORBIDDEN: 'Operation is not permitted.', NOT_FOUND: 'Resource is unavailable.',
13
+ VERSION_CONFLICT: 'Target changed. Read it again and confirm a new plan.',
14
+ OPERATION_CONFLICT: 'Operation ID already belongs to another request.', BATCH_CLOSED: 'Import batch is closed.',
15
+ UNSUPPORTED_EDIT: 'Edit cannot preserve rich content. Use the website editor.', RATE_LIMITED: 'Rate limited. Retry later.',
16
+ STORAGE_UNAVAILABLE: 'Storage is unavailable.', RESULT_UNKNOWN: 'Result is uncertain. Resume using the original operation ID.',
17
+ PROTOCOL_MISMATCH: 'CLI and website are incompatible. Update the CLI or website; no legacy fallback.',
18
+ NETWORK_ERROR: 'Request failed. Remote result may be unknown; no automatic retry.',
19
+ UNSAFE_PATH: 'Unsafe path: check owner, permissions and symlinks.',
20
+ CONFIRMATION_REQUIRED: 'Plan hash or site/account does not match. Preview and confirm again.',
21
+ SOURCE_CHANGED: 'Source bytes changed. Scan and confirm a new plan.',
22
+ AUTH_DENIED: 'Authorization was declined.', AUTH_TIMEOUT: 'Authorization timed out.',
23
+ BROWSER_FAILED: 'Could not open the browser. Authorization was not completed.',
24
+ SAVE_FAILED: 'Credential could not be saved; revocation was attempted.',
25
+ REVOCATION_FAILED: 'Server credential has NOT been revoked; local credential was retained.',
26
+ SKILL_CONFLICT: 'Installed Skill has unknown or modified content; it was not overwritten.',
27
+ NOT_PUBLISHED: 'This private placeholder package is not published. Remote update is blocked until package ownership is verified.',
28
+ NOT_GLOBAL: 'Not a global npm installation. Update using the original local installation method.',
29
+ UPDATE_FAILED: 'npm update failed. No privilege escalation was attempted.',
30
+ INVALID_RESPONSE: 'Server returned an invalid response.', INTERNAL_ERROR: 'Command failed; sensitive diagnostics were suppressed.',
31
+ TOOL_UNAVAILABLE: 'Required macOS tool is missing: scans need /usr/bin/sips; zip input also needs /usr/bin/unzip.',
32
+ };
33
+ export class CliError extends Error {
34
+ code;
35
+ retryable;
36
+ status;
37
+ constructor(code, retryable = false, status) {
38
+ super(messages[code] ?? messages.INTERNAL_ERROR);
39
+ this.code = code;
40
+ this.retryable = retryable;
41
+ this.status = status;
42
+ }
43
+ }
44
+ export function safeError(error) {
45
+ if (error instanceof CliError && Object.hasOwn(messages, error.code))
46
+ return { code: error.code, message: messages[error.code], retryable: error.retryable };
47
+ return { code: 'INTERNAL_ERROR', message: messages.INTERNAL_ERROR, retryable: false };
48
+ }
49
+ export function normalizeSite(site = DEFAULT_SITE) {
50
+ let u;
51
+ try {
52
+ u = new URL(site);
53
+ }
54
+ catch {
55
+ throw new CliError('INVALID_INPUT');
56
+ }
57
+ const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname);
58
+ if ((u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) || u.username || u.password || u.search || u.hash || u.pathname !== '/')
59
+ throw new CliError('INVALID_INPUT');
60
+ return u.origin;
61
+ }
62
+ export function sha256(value) { return createHash('sha256').update(value).digest('hex'); }
63
+ export function canonicalJson(value) {
64
+ if (value === null || typeof value !== 'object')
65
+ return JSON.stringify(value);
66
+ if (Array.isArray(value))
67
+ return `[${value.map(canonicalJson).join(',')}]`;
68
+ const record = value;
69
+ return `{${Object.keys(record).filter(k => record[k] !== undefined).sort().map(k => `${JSON.stringify(k)}:${canonicalJson(record[k])}`).join(',')}}`;
70
+ }
71
+ export function checkBytes(value, max = LIMITS.maxRequestBytes) {
72
+ const json = JSON.stringify(value);
73
+ if (Buffer.byteLength(json, 'utf8') > max)
74
+ throw new CliError('PAYLOAD_TOO_LARGE');
75
+ return json;
76
+ }
77
+ export function publicOutput(value, allowImageUrls = false, parentKey = '') {
78
+ if (Array.isArray(value))
79
+ return value.map(v => publicOutput(v, allowImageUrls, parentKey));
80
+ if (value && typeof value === 'object')
81
+ return Object.fromEntries(Object.entries(value).filter(([k]) => !(k === 'code' && parentKey !== 'error') && !/^(token|authorization|verifier|state|challenge|policy|signature|fields|accessKeyId|accessKeySecret|securityToken)$/i.test(k)).map(([k, v]) => [k, publicOutput(v, allowImageUrls, k)]));
82
+ if (typeof value === 'string') {
83
+ const clean = value.replace(/fam_(?:cli|imp)_[A-Za-z0-9_-]+/g, '[redacted]');
84
+ return !allowImageUrls && /url$/i.test(parentKey) ? clean.replace(/(https?:\/\/[^\s?]+)\?[^\s]+/g, '$1?[redacted]') : clean;
85
+ }
86
+ return value;
87
+ }
@@ -0,0 +1,128 @@
1
+ import { z } from 'zod';
2
+ import { safeError } from './core.js';
3
+ import { ApiClient } from './http.js';
4
+ import { type Plan, type ResultRefs } from './plan.js';
5
+ import type { Candidate } from './manifest.js';
6
+ export declare const progressSchema: z.ZodObject<{
7
+ schemaVersion: z.ZodLiteral<1>;
8
+ origin: z.ZodString;
9
+ userId: z.ZodString;
10
+ entries: z.ZodRecord<z.ZodString, z.ZodPipe<z.ZodObject<{
11
+ fingerprint: z.ZodString;
12
+ requestHash: z.ZodOptional<z.ZodString>;
13
+ request: z.ZodOptional<z.ZodObject<{
14
+ path: z.ZodString;
15
+ method: z.ZodEnum<{
16
+ POST: "POST";
17
+ PATCH: "PATCH";
18
+ }>;
19
+ body: z.ZodJSONSchema;
20
+ version: z.ZodOptional<z.ZodString>;
21
+ }, z.core.$strict>>;
22
+ status: z.ZodEnum<{
23
+ unknown: "unknown";
24
+ success: "success";
25
+ failed: "failed";
26
+ }>;
27
+ result: z.ZodOptional<z.ZodObject<{
28
+ type: z.ZodOptional<z.ZodString>;
29
+ id: z.ZodString;
30
+ version: z.ZodOptional<z.ZodString>;
31
+ status: z.ZodOptional<z.ZodString>;
32
+ replayed: z.ZodOptional<z.ZodBoolean>;
33
+ candidateId: z.ZodOptional<z.ZodString>;
34
+ candidates: z.ZodOptional<z.ZodArray<z.ZodObject<{
35
+ localCandidateId: z.ZodString;
36
+ id: z.ZodString;
37
+ }, z.core.$strip>>>;
38
+ photoIds: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
39
+ }, z.core.$strip>>;
40
+ error: z.ZodOptional<z.ZodObject<{
41
+ code: z.ZodString;
42
+ message: z.ZodString;
43
+ retryable: z.ZodBoolean;
44
+ }, z.core.$strip>>;
45
+ }, z.core.$strict>, z.ZodTransform<{
46
+ requestHash: string;
47
+ fingerprint: string;
48
+ status: "unknown" | "success" | "failed";
49
+ request?: {
50
+ path: string;
51
+ method: "POST" | "PATCH";
52
+ body: z.core.util.JSONType;
53
+ version?: string | undefined;
54
+ } | undefined;
55
+ result?: {
56
+ id: string;
57
+ type?: string | undefined;
58
+ version?: string | undefined;
59
+ status?: string | undefined;
60
+ replayed?: boolean | undefined;
61
+ candidateId?: string | undefined;
62
+ candidates?: {
63
+ localCandidateId: string;
64
+ id: string;
65
+ }[] | undefined;
66
+ photoIds?: Record<string, string> | undefined;
67
+ } | undefined;
68
+ error?: {
69
+ code: string;
70
+ message: string;
71
+ retryable: boolean;
72
+ } | undefined;
73
+ }, {
74
+ fingerprint: string;
75
+ status: "unknown" | "success" | "failed";
76
+ requestHash?: string | undefined;
77
+ request?: {
78
+ path: string;
79
+ method: "POST" | "PATCH";
80
+ body: z.core.util.JSONType;
81
+ version?: string | undefined;
82
+ } | undefined;
83
+ result?: {
84
+ id: string;
85
+ type?: string | undefined;
86
+ version?: string | undefined;
87
+ status?: string | undefined;
88
+ replayed?: boolean | undefined;
89
+ candidateId?: string | undefined;
90
+ candidates?: {
91
+ localCandidateId: string;
92
+ id: string;
93
+ }[] | undefined;
94
+ photoIds?: Record<string, string> | undefined;
95
+ } | undefined;
96
+ error?: {
97
+ code: string;
98
+ message: string;
99
+ retryable: boolean;
100
+ } | undefined;
101
+ }>>>;
102
+ }, z.core.$strict>;
103
+ export type Progress = z.infer<typeof progressSchema>;
104
+ type ItemResult = {
105
+ operationId: string;
106
+ status: 'success' | 'failed' | 'unknown' | 'skipped';
107
+ result?: ResultRefs;
108
+ error?: ReturnType<typeof safeError>;
109
+ candidates?: ItemResult[];
110
+ };
111
+ export type ExecutionResult = {
112
+ ok: boolean;
113
+ items: ItemResult[];
114
+ progress: Progress;
115
+ };
116
+ export declare function stepId(operationId: string, step: string): string;
117
+ declare const uploadSchema: z.ZodObject<{
118
+ endpoint: z.ZodURL;
119
+ key: z.ZodString;
120
+ expiresAt: z.ZodISODateTime;
121
+ fields: z.ZodRecord<z.ZodString, z.ZodString>;
122
+ }, z.core.$strip>;
123
+ export declare function uploadPhoto(api: ApiClient, upload: z.infer<typeof uploadSchema>, file: string, candidate: Candidate): Promise<void>;
124
+ export declare function executePlan(input: Plan, hash: string, identity: {
125
+ origin: string;
126
+ userId: string;
127
+ }, api: ApiClient, previous: unknown, save: (progress: Progress) => Promise<void>): Promise<ExecutionResult>;
128
+ export {};
@@ -0,0 +1,210 @@
1
+ import { z } from 'zod';
2
+ import { canonicalJson, checkBytes, CliError, safeError, sha256 } from './core.js';
3
+ import { actionRequest, refsIn, resultSchema, verifyPlan } from './plan.js';
4
+ import { readPhoto } from './scan.js';
5
+ import { serializePrivateJson } from './storage.js';
6
+ const requestSchema = z.object({ path: z.string(), method: z.enum(['POST', 'PATCH']), body: z.json(), version: z.string().optional() }).strict();
7
+ const entrySchema = z.object({
8
+ fingerprint: z.string(), requestHash: z.string().regex(/^[a-f0-9]{64}$/).optional(), request: requestSchema.optional(),
9
+ status: z.enum(['success', 'unknown', 'failed']), result: resultSchema.optional(),
10
+ error: z.object({ code: z.string(), message: z.string(), retryable: z.boolean() }).optional(),
11
+ }).strict().superRefine((entry, ctx) => {
12
+ if (entry.status !== 'success' && !entry.request || entry.status === 'success' && (!entry.result || !entry.requestHash && !entry.request)) {
13
+ ctx.addIssue({ code: 'custom', message: 'Incomplete operation progress' });
14
+ }
15
+ if (entry.request && entry.requestHash && entry.requestHash !== sha256(canonicalJson(entry.request))) {
16
+ ctx.addIssue({ code: 'custom', message: 'Frozen request hash mismatch' });
17
+ }
18
+ }).transform(entry => {
19
+ const normalized = { ...entry, requestHash: entry.requestHash ?? sha256(canonicalJson(entry.request)) };
20
+ // Legacy success entries are compacted without dropping their identity or receipt.
21
+ if (normalized.status === 'success') {
22
+ delete normalized.request;
23
+ delete normalized.error;
24
+ }
25
+ return normalized;
26
+ });
27
+ export const progressSchema = z.object({ schemaVersion: z.literal(1), origin: z.string(), userId: z.string(), entries: z.record(z.string(), entrySchema) }).strict();
28
+ export function stepId(operationId, step) {
29
+ const hex = sha256(`${operationId}:${step}`).slice(0, 32).split('');
30
+ hex[12] = '4';
31
+ hex[16] = '8';
32
+ const h = hex.join('');
33
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
34
+ }
35
+ const uploadSchema = z.object({ endpoint: z.url(), key: z.string(), expiresAt: z.iso.datetime(), fields: z.record(z.string(), z.string()) });
36
+ export async function uploadPhoto(api, upload, file, candidate) {
37
+ const url = new URL(upload.endpoint);
38
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || upload.fields['x-oss-object-acl'] !== 'private' || upload.fields.key !== upload.key || upload.fields['Content-Type'] !== candidate.mimeType || !upload.fields['x-oss-security-token'] || Date.parse(upload.expiresAt) <= Date.now())
39
+ throw new CliError('INVALID_RESPONSE');
40
+ const bytes = await readPhoto(file, candidate);
41
+ const form = new FormData();
42
+ for (const [key, value] of Object.entries(upload.fields)) {
43
+ if (key.toLowerCase() === 'file')
44
+ throw new CliError('INVALID_RESPONSE');
45
+ form.append(key, value);
46
+ }
47
+ form.append('file', new Blob([new Uint8Array(bytes)], { type: candidate.mimeType }), candidate.originalName);
48
+ let res;
49
+ try {
50
+ res = await api.fetcher(url, { method: 'POST', body: form, redirect: 'manual', signal: AbortSignal.timeout(120000) });
51
+ }
52
+ catch {
53
+ throw new CliError('RESULT_UNKNOWN', true);
54
+ }
55
+ await res.body?.cancel();
56
+ if (!res.ok)
57
+ throw new CliError('STORAGE_UNAVAILABLE', true);
58
+ }
59
+ export async function executePlan(input, hash, identity, api, previous, save) {
60
+ const plan = verifyPlan(input, hash, identity);
61
+ const progress = previous ? progressSchema.parse(previous) : { schemaVersion: 1, origin: plan.origin, userId: plan.userId, entries: {} };
62
+ let persistenceFailed = false;
63
+ async function persist() { try {
64
+ serializePrivateJson(progress);
65
+ await save(progress);
66
+ }
67
+ catch (e) {
68
+ persistenceFailed = true;
69
+ throw e;
70
+ } }
71
+ if (progress.origin !== plan.origin || progress.userId !== plan.userId)
72
+ throw new CliError('CONFIRMATION_REQUIRED');
73
+ await api.negotiate();
74
+ if (plan.items.length > api.limits.maxActions)
75
+ throw new CliError('PAYLOAD_TOO_LARGE');
76
+ // Preflight every known payload and source before the first remote content write.
77
+ for (const item of plan.items) {
78
+ checkBytes(item.payload, api.limits.maxRequestBytes);
79
+ if (item.action === 'photos.import') {
80
+ if (item.payload.manifest.candidates.length > api.limits.maxCandidates)
81
+ throw new CliError('PAYLOAD_TOO_LARGE');
82
+ for (const c of item.payload.manifest.candidates.filter(c => c.status === 'SELECTED')) {
83
+ if (c.fileSize > api.limits.maxFileBytes)
84
+ throw new CliError('PAYLOAD_TOO_LARGE');
85
+ if (progress.entries[stepId(item.operationId, `${c.localCandidateId}:complete`)]?.status !== 'success' && progress.entries[stepId(item.operationId, `${c.localCandidateId}:publish`)]?.status !== 'success')
86
+ await readPhoto(item.files[c.localCandidateId], c);
87
+ }
88
+ }
89
+ const entry = progress.entries[item.operationId];
90
+ if (entry && entry.fingerprint !== sha256(canonicalJson(item)))
91
+ throw new CliError('OPERATION_CONFLICT');
92
+ }
93
+ await persist();
94
+ async function perform(operationId, fingerprint, request, ephemeral = false) {
95
+ checkBytes(request.body, api.limits.maxRequestBytes);
96
+ const requestHash = sha256(canonicalJson(request));
97
+ let entry = progress.entries[operationId];
98
+ if (entry && (entry.fingerprint !== fingerprint || entry.requestHash !== requestHash))
99
+ throw new CliError('OPERATION_CONFLICT');
100
+ if (entry && !ephemeral && entry.status !== 'failed') {
101
+ try {
102
+ const result = resultSchema.parse(await api.request(`/operations/${operationId}`));
103
+ entry.status = 'success';
104
+ entry.result = result;
105
+ delete entry.request;
106
+ delete entry.error;
107
+ await persist();
108
+ return result;
109
+ }
110
+ catch (e) {
111
+ if (persistenceFailed || !(e instanceof CliError && e.code === 'NOT_FOUND') || entry.status === 'success')
112
+ throw e;
113
+ }
114
+ }
115
+ entry = { fingerprint, requestHash, request: requestSchema.parse(request), status: 'unknown' };
116
+ progress.entries[operationId] = entry;
117
+ await persist();
118
+ let raw;
119
+ try {
120
+ raw = await api.request(request.path, { method: request.method, body: request.body, version: request.version, operationId });
121
+ const result = resultSchema.parse(raw);
122
+ entry.status = 'success';
123
+ entry.result = result;
124
+ delete entry.request;
125
+ }
126
+ catch (e) {
127
+ entry.status = e instanceof CliError && !['RESULT_UNKNOWN', 'NETWORK_ERROR', 'INVALID_RESPONSE', 'STORAGE_UNAVAILABLE'].includes(e.code) ? 'failed' : 'unknown';
128
+ entry.error = safeError(e);
129
+ await persist();
130
+ throw e;
131
+ }
132
+ await persist();
133
+ return ephemeral ? { ...entry.result, upload: uploadSchema.parse(raw.upload) } : entry.result;
134
+ }
135
+ async function importPhotos(item) {
136
+ const fingerprint = sha256(canonicalJson(item));
137
+ const batch = await perform(item.operationId, fingerprint, actionRequest(item, {}));
138
+ if (!batch.candidates || batch.candidates.length !== item.payload.manifest.candidates.length || new Set(batch.candidates.map(c => c.localCandidateId)).size !== batch.candidates.length)
139
+ throw new CliError('INVALID_RESPONSE');
140
+ const state = await api.request(`/imports/${encodeURIComponent(batch.id)}`);
141
+ if (!Array.isArray(state.candidates))
142
+ throw new CliError('INVALID_RESPONSE');
143
+ const selected = item.payload.manifest.candidates.filter(c => c.status === 'SELECTED');
144
+ const details = [];
145
+ const paths = new Map();
146
+ for (const candidate of selected) {
147
+ const mapping = batch.candidates.find(c => c.localCandidateId === candidate.localCandidateId);
148
+ if (!mapping)
149
+ throw new CliError('INVALID_RESPONSE');
150
+ const path = `/imports/${encodeURIComponent(batch.id)}/candidates/${encodeURIComponent(mapping.id)}`;
151
+ paths.set(candidate.localCandidateId, path);
152
+ const current = state.candidates.find(c => c.id === mapping.id && c.localCandidateId === candidate.localCandidateId);
153
+ if (!current)
154
+ throw new CliError('INVALID_RESPONSE');
155
+ if (current.verified || current.publishedPhotoId)
156
+ continue;
157
+ const uploadId = stepId(item.operationId, `${candidate.localCandidateId}:upload`);
158
+ try {
159
+ const authorization = await perform(uploadId, fingerprint, { path: `${path}/upload`, method: 'POST', body: {} }, true);
160
+ await uploadPhoto(api, authorization.upload, item.files[candidate.localCandidateId], candidate);
161
+ await perform(stepId(item.operationId, `${candidate.localCandidateId}:complete`), fingerprint, { path: `${path}/complete`, method: 'POST', body: {} });
162
+ }
163
+ catch (e) {
164
+ if (persistenceFailed)
165
+ throw e;
166
+ details.push({ operationId: uploadId, status: e instanceof CliError && e.code === 'RESULT_UNKNOWN' ? 'unknown' : 'failed', error: safeError(e) });
167
+ }
168
+ }
169
+ // Do not silently publish only the uploaded subset of the confirmed selection.
170
+ if (details.length)
171
+ return { operationId: item.operationId, status: 'failed', result: batch, candidates: details };
172
+ const photoIds = {};
173
+ for (const candidate of selected) {
174
+ const operationId = stepId(item.operationId, `${candidate.localCandidateId}:publish`);
175
+ const expected = item.expectations[candidate.localCandidateId];
176
+ try {
177
+ const result = await perform(operationId, fingerprint, { path: `${paths.get(candidate.localCandidateId)}/publish`, method: 'POST', body: { expectedPhoto: expected.mode === 'create' ? null : { id: expected.photoId, version: expected.version } } });
178
+ photoIds[candidate.localCandidateId] = result.id;
179
+ details.push({ operationId, status: 'success', result });
180
+ }
181
+ catch (e) {
182
+ if (persistenceFailed)
183
+ throw e;
184
+ details.push({ operationId, status: progress.entries[operationId]?.status === 'unknown' ? 'unknown' : 'failed', error: safeError(e) });
185
+ }
186
+ }
187
+ return { operationId: item.operationId, status: details.every(d => d.status === 'success') ? 'success' : 'failed', result: { ...batch, photoIds }, candidates: details };
188
+ }
189
+ const results = {};
190
+ const items = [];
191
+ for (const item of plan.items) {
192
+ if (refsIn(item).some(r => !results[r.ref])) {
193
+ items.push({ operationId: item.operationId, status: 'skipped' });
194
+ continue;
195
+ }
196
+ try {
197
+ const entry = progress.entries[item.operationId];
198
+ const outcome = item.action === 'photos.import' ? await importPhotos(item) : { operationId: item.operationId, status: 'success', result: await perform(item.operationId, sha256(canonicalJson(item)), entry?.request ?? actionRequest(item, results)) };
199
+ items.push(outcome);
200
+ if (outcome.status === 'success')
201
+ results[item.operationId] = outcome.result;
202
+ }
203
+ catch (e) {
204
+ if (persistenceFailed)
205
+ throw e;
206
+ items.push({ operationId: item.operationId, status: progress.entries[item.operationId]?.status === 'unknown' ? 'unknown' : 'failed', error: safeError(e) });
207
+ }
208
+ }
209
+ return { ok: items.every(i => i.status === 'success'), items, progress };
210
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ export declare function compatibleVersion(version: string, range: string): boolean;
3
+ export declare const userSchema: z.ZodObject<{
4
+ id: z.ZodString;
5
+ email: z.ZodString;
6
+ name: z.ZodNullable<z.ZodString>;
7
+ role: z.ZodEnum<{
8
+ FAMILY: "FAMILY";
9
+ ADMIN: "ADMIN";
10
+ }>;
11
+ }, z.core.$strip>;
12
+ export declare const meSchema: z.ZodObject<{
13
+ id: z.ZodString;
14
+ email: z.ZodString;
15
+ name: z.ZodNullable<z.ZodString>;
16
+ role: z.ZodEnum<{
17
+ FAMILY: "FAMILY";
18
+ ADMIN: "ADMIN";
19
+ }>;
20
+ expiresAt: z.ZodISODateTime;
21
+ }, z.core.$strip>;
22
+ export type RequestOptions = {
23
+ method?: 'GET' | 'POST' | 'PATCH';
24
+ body?: unknown;
25
+ operationId?: string;
26
+ version?: string;
27
+ anonymous?: boolean;
28
+ };
29
+ export declare class ApiClient {
30
+ token?: string | undefined;
31
+ readonly fetcher: typeof fetch;
32
+ readonly origin: string;
33
+ limits: {
34
+ maxRequestBytes: number;
35
+ maxCandidates: number;
36
+ maxActions: number;
37
+ maxFileBytes: number;
38
+ };
39
+ private negotiated;
40
+ constructor(site: string, token?: string | undefined, fetcher?: typeof fetch);
41
+ negotiate(): Promise<void>;
42
+ request<T = Record<string, unknown>>(path: string, options?: RequestOptions): Promise<T>;
43
+ private raw;
44
+ }
package/dist/http.js ADDED
@@ -0,0 +1,146 @@
1
+ import { z } from 'zod';
2
+ import { checkBytes, CliError, LIMITS, normalizeSite, VERSION } from './core.js';
3
+ export function compatibleVersion(version, range) {
4
+ const parse = (v) => {
5
+ const m = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta\.(0|[1-9]\d*))?$/.exec(v);
6
+ if (!m)
7
+ return null;
8
+ const parts = m.slice(1, 4).map(Number);
9
+ if (!parts.every(Number.isSafeInteger) || (m[4] !== undefined && !Number.isSafeInteger(Number(m[4]))))
10
+ return null;
11
+ return [...parts, m[4] === undefined ? Infinity : Number(m[4])];
12
+ };
13
+ const actual = parse(version);
14
+ if (!actual)
15
+ return false;
16
+ const compare = (a, b) => { for (let i = 0; i < 4; i++)
17
+ if (a[i] !== b[i])
18
+ return a[i] > b[i] ? 1 : -1; return 0; };
19
+ return range.split('||').some(clause => {
20
+ const terms = clause.trim().split(/\s+/);
21
+ // Prereleases require an explicit comparator for the same release tuple.
22
+ if (actual[3] !== Infinity && !terms.some(term => {
23
+ const v = parse(term.replace(/^(>=|<=|>|<|=|\^|~)/, ''));
24
+ return v && v[3] !== Infinity && v.slice(0, 3).every((n, i) => n === actual[i]);
25
+ }))
26
+ return false;
27
+ return terms.every(term => {
28
+ if (term === '*')
29
+ return true;
30
+ const m = /^(>=|<=|>|<|=|\^|~)?(\d+\.\d+\.\d+(?:-beta\.\d+)?)$/.exec(term);
31
+ if (!m)
32
+ return false;
33
+ const v = parse(m[2]);
34
+ if (!v)
35
+ return false;
36
+ const c = compare(actual, v);
37
+ if (m[1] === '^' || m[1] === '~') {
38
+ const upper = [...v.slice(0, 3), -1];
39
+ const index = m[1] === '~' ? 1 : v[0] ? 0 : v[1] ? 1 : 2;
40
+ upper[index]++;
41
+ for (let i = index + 1; i < 3; i++)
42
+ upper[i] = 0;
43
+ return c >= 0 && compare(actual, upper) < 0;
44
+ }
45
+ return m[1] === '>=' ? c >= 0 : m[1] === '<=' ? c <= 0 : m[1] === '>' ? c > 0 : m[1] === '<' ? c < 0 : c === 0;
46
+ });
47
+ });
48
+ }
49
+ const metaSchema = z.object({ protocolVersion: z.literal(1), cliVersionRange: z.string(), limits: z.object({ maxRequestBytes: z.number().int().positive(), maxCandidates: z.number().int().positive(), maxActions: z.number().int().positive(), maxFileBytes: z.number().int().positive() }).strict() }).strict();
50
+ export const userSchema = z.object({ id: z.string(), email: z.string(), name: z.string().nullable(), role: z.enum(['FAMILY', 'ADMIN']) });
51
+ export const meSchema = userSchema.extend({ expiresAt: z.iso.datetime() });
52
+ export class ApiClient {
53
+ token;
54
+ fetcher;
55
+ origin;
56
+ limits = { ...LIMITS };
57
+ negotiated = false;
58
+ constructor(site, token, fetcher = fetch) {
59
+ this.token = token;
60
+ this.fetcher = fetcher;
61
+ this.origin = normalizeSite(site);
62
+ }
63
+ async negotiate() {
64
+ if (this.negotiated)
65
+ return;
66
+ let meta;
67
+ try {
68
+ meta = metaSchema.parse(await this.raw('/meta', { anonymous: true }));
69
+ }
70
+ catch (e) {
71
+ if (e instanceof CliError && e.code === 'NETWORK_ERROR')
72
+ throw e;
73
+ throw new CliError('PROTOCOL_MISMATCH');
74
+ }
75
+ if (!compatibleVersion(VERSION, meta.cliVersionRange))
76
+ throw new CliError('PROTOCOL_MISMATCH');
77
+ for (const k of Object.keys(LIMITS))
78
+ this.limits[k] = Math.min(LIMITS[k], meta.limits[k]);
79
+ this.negotiated = true;
80
+ }
81
+ async request(path, options = {}) {
82
+ if (!/^\/[a-z][^\\#]*$/.test(path) || /(?:^|\/)\.\.(?:\/|$)/.test(path))
83
+ throw new CliError('INVALID_INPUT');
84
+ if (options.body !== undefined)
85
+ checkBytes(options.body, this.limits.maxRequestBytes);
86
+ await this.negotiate();
87
+ return this.raw(path, options);
88
+ }
89
+ async raw(path, options) {
90
+ const headers = { Accept: 'application/json', 'X-Yusijia-Protocol': '1' };
91
+ if (!options.anonymous) {
92
+ if (!this.token)
93
+ throw new CliError('AUTH_REQUIRED');
94
+ headers.Authorization = `Bearer ${this.token}`;
95
+ }
96
+ if (options.operationId)
97
+ headers['Idempotency-Key'] = options.operationId;
98
+ if (options.version)
99
+ headers['If-Match'] = options.version;
100
+ if (options.body !== undefined)
101
+ headers['Content-Type'] = 'application/json';
102
+ let response;
103
+ try {
104
+ response = await this.fetcher(`${this.origin}/api/agent/v1${path}`, { method: options.method ?? 'GET', headers, body: options.body === undefined ? undefined : checkBytes(options.body, this.limits.maxRequestBytes), redirect: 'manual', signal: AbortSignal.timeout(30000) });
105
+ }
106
+ catch {
107
+ throw new CliError(options.method && options.method !== 'GET' ? 'RESULT_UNKNOWN' : 'NETWORK_ERROR', true);
108
+ }
109
+ if (response.status >= 300 && response.status < 400)
110
+ throw new CliError('INVALID_RESPONSE');
111
+ const reader = response.body?.getReader();
112
+ if (!reader)
113
+ throw new CliError('INVALID_RESPONSE');
114
+ const parts = [];
115
+ let bytes = 0;
116
+ try {
117
+ while (true) {
118
+ const chunk = await reader.read();
119
+ if (chunk.done)
120
+ break;
121
+ bytes += chunk.value.length;
122
+ if (bytes > 8 * 1024 * 1024) {
123
+ await reader.cancel();
124
+ throw new CliError('INVALID_RESPONSE');
125
+ }
126
+ ;
127
+ parts.push(chunk.value);
128
+ }
129
+ }
130
+ catch (e) {
131
+ if (e instanceof CliError)
132
+ throw e;
133
+ throw new CliError('RESULT_UNKNOWN', true);
134
+ }
135
+ let envelope;
136
+ try {
137
+ envelope = JSON.parse(Buffer.concat(parts).toString('utf8'));
138
+ }
139
+ catch {
140
+ throw new CliError(response.status >= 500 ? 'RESULT_UNKNOWN' : 'INVALID_RESPONSE', response.status >= 500);
141
+ }
142
+ if (!envelope || envelope.ok !== true || !response.ok)
143
+ throw new CliError(envelope?.error?.code ?? (response.status >= 500 ? 'RESULT_UNKNOWN' : 'INVALID_RESPONSE'), envelope?.error?.retryable === true, response.status);
144
+ return envelope.data;
145
+ }
146
+ }