@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/README.md +194 -0
- package/dist/auth.d.ts +24 -0
- package/dist/auth.js +115 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +210 -0
- package/dist/core.d.ts +24 -0
- package/dist/core.js +87 -0
- package/dist/execute.d.ts +128 -0
- package/dist/execute.js +210 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +146 -0
- package/dist/manifest.d.ts +70 -0
- package/dist/manifest.js +18 -0
- package/dist/plan.d.ts +782 -0
- package/dist/plan.js +119 -0
- package/dist/scan.d.ts +33 -0
- package/dist/scan.js +262 -0
- package/dist/skills.d.ts +19 -0
- package/dist/skills.js +128 -0
- package/dist/storage.d.ts +29 -0
- package/dist/storage.js +139 -0
- package/package.json +23 -0
- package/skills/family-content/SKILL.md +94 -0
package/dist/plan.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { canonicalJson, checkBytes, CliError, LIMITS, normalizeSite, sha256 } from './core.js';
|
|
4
|
+
import { manifestSchema } from './manifest.js';
|
|
5
|
+
export { canonicalJson } from './core.js';
|
|
6
|
+
const id = z.string().min(1).max(191);
|
|
7
|
+
export const refSchema = z.union([
|
|
8
|
+
z.object({ ref: z.uuid(), field: z.enum(['id', 'version']) }).strict(),
|
|
9
|
+
z.object({ ref: z.uuid(), field: z.literal('photoId'), localCandidateId: z.string().min(1).max(100).regex(/^[a-zA-Z0-9_-]+$/) }).strict(),
|
|
10
|
+
]);
|
|
11
|
+
const value = z.union([id, refSchema]);
|
|
12
|
+
const photoIds = (max) => z.array(value).max(max).transform(ids => Array.from(new Map(ids.map(v => [canonicalJson(v), v])).values()));
|
|
13
|
+
const base = { operationId: z.uuid() };
|
|
14
|
+
const target = { target: value, expectedVersion: z.union([z.iso.datetime(), refSchema.refine(r => r.field === 'version')]) };
|
|
15
|
+
const albumFields = { name: z.string().trim().min(1).max(50), description: z.string().max(200).optional() };
|
|
16
|
+
const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(v => { const d = new Date(`${v}T00:00:00.000Z`); return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v; });
|
|
17
|
+
const milestoneFields = { date, title: z.string().trim().min(1).max(100), description: z.string().max(2000).optional(), photoIds: photoIds(3).optional() };
|
|
18
|
+
const textBlock = z.object({ id: id.optional(), type: z.enum(['paragraph', 'heading', 'bulletListItem', 'numberedListItem']), text: z.string().max(100000), level: z.number().int().min(1).max(3).optional() }).strict().refine(b => b.level === undefined || b.type === 'heading');
|
|
19
|
+
const blocks = z.array(textBlock).min(1).max(500).refine(bs => bs.reduce((n, b) => n + b.text.length, 0) <= 100000 && new Set(bs.filter(b => b.id).map(b => b.id)).size === bs.filter(b => b.id).length);
|
|
20
|
+
const edit = z.discriminatedUnion('type', [z.object({ type: z.literal('replaceText'), blockId: id, text: z.string().max(100000) }).strict(), z.object({ type: z.literal('append'), blocks }).strict()]);
|
|
21
|
+
const diaryEdit = z.object({ edits: z.array(edit).min(1).max(500).optional(), status: z.enum(['DRAFT', 'PUBLISHED']).optional() }).strict().refine(p => p.edits !== undefined || p.status !== undefined);
|
|
22
|
+
export const actionSchema = z.discriminatedUnion('action', [
|
|
23
|
+
z.object({ ...base, action: z.literal('albums.create'), payload: z.object(albumFields).strict() }).strict(),
|
|
24
|
+
z.object({ ...base, ...target, action: z.literal('albums.update'), payload: z.object(albumFields).partial().strict().refine(p => Object.keys(p).length > 0) }).strict(),
|
|
25
|
+
z.object({ ...base, ...target, action: z.literal('albums.photos'), payload: z.object({ add: photoIds(200), remove: photoIds(200) }).strict().refine(p => p.add.length + p.remove.length > 0 && p.add.length + p.remove.length <= 200 && !p.add.some(a => p.remove.some(b => canonicalJson(a) === canonicalJson(b)))) }).strict(),
|
|
26
|
+
z.object({ ...base, action: z.literal('timeline.create'), payload: z.object(milestoneFields).strict() }).strict(),
|
|
27
|
+
z.object({ ...base, ...target, action: z.literal('timeline.update'), payload: z.object(milestoneFields).partial().strict().refine(p => Object.keys(p).length > 0) }).strict(),
|
|
28
|
+
z.object({ ...base, action: z.literal('diary.create'), payload: z.object({ blocks, status: z.enum(['DRAFT', 'PUBLISHED']).default('DRAFT') }).strict() }).strict(),
|
|
29
|
+
z.object({ ...base, ...target, action: z.literal('diary.edit'), payload: diaryEdit }).strict(),
|
|
30
|
+
z.object({ ...base, ...target, action: z.literal('diary.publish'), payload: z.object({ status: z.literal('PUBLISHED') }).strict() }).strict(),
|
|
31
|
+
z.object({ ...base, action: z.literal('photos.import'), payload: z.object({ manifest: manifestSchema }).strict(), files: z.record(z.string(), z.string().min(1)), expectations: z.record(z.string(), z.union([z.object({ mode: z.literal('create') }).strict(), z.object({ mode: z.literal('reuse'), photoId: z.string().min(1).max(100), version: z.iso.datetime() }).strict()])) }).strict(),
|
|
32
|
+
z.object({ ...base, action: z.literal('photos.cancel'), target: value, payload: z.object({}).strict() }).strict(),
|
|
33
|
+
]);
|
|
34
|
+
const planCore = z.object({ schemaVersion: z.literal(1), origin: z.string().transform(normalizeSite), userId: id, items: z.array(actionSchema).min(1).max(LIMITS.maxActions) }).strict();
|
|
35
|
+
export const planSchema = planCore.extend({ planHash: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
36
|
+
export const resultSchema = z.object({ type: z.string().optional(), id: z.string().min(1).max(200), version: z.string().max(200).optional(), status: z.string().max(100).optional(), replayed: z.boolean().optional(), candidateId: z.string().max(200).optional(), candidates: z.array(z.object({ localCandidateId: z.string().max(100), id: z.string().max(200) })).max(200).optional(), photoIds: z.record(z.string(), z.string()).optional() });
|
|
37
|
+
export function refsIn(v) {
|
|
38
|
+
if (!v || typeof v !== 'object')
|
|
39
|
+
return [];
|
|
40
|
+
if (Object.hasOwn(v, 'ref'))
|
|
41
|
+
return [refSchema.parse(v)];
|
|
42
|
+
return Object.values(v).flatMap(refsIn);
|
|
43
|
+
}
|
|
44
|
+
function validateOrder(items) {
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (const item of items) {
|
|
47
|
+
if (seen.has(item.operationId) || refsIn(item).some(r => !seen.has(r.ref) || r.field === 'photoId' && !items.some(i => i.operationId === r.ref && i.action === 'photos.import' && i.payload.manifest.candidates.some(c => c.localCandidateId === r.localCandidateId && c.status === 'SELECTED'))))
|
|
48
|
+
throw new CliError('INVALID_INPUT');
|
|
49
|
+
seen.add(item.operationId);
|
|
50
|
+
checkBytes(item.payload);
|
|
51
|
+
if (item.action === 'photos.import')
|
|
52
|
+
for (const c of item.payload.manifest.candidates.filter(c => c.status === 'SELECTED')) {
|
|
53
|
+
if (!item.files[c.localCandidateId] || !item.expectations[c.localCandidateId])
|
|
54
|
+
throw new CliError('INVALID_INPUT');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function buildPlan(input) {
|
|
59
|
+
const raw = input;
|
|
60
|
+
const core = planCore.parse({ schemaVersion: 1, ...raw });
|
|
61
|
+
validateOrder(core.items);
|
|
62
|
+
return { ...core, planHash: sha256(canonicalJson(core)) };
|
|
63
|
+
}
|
|
64
|
+
export function verifyPlan(input, confirmed, identity) {
|
|
65
|
+
const { planHash, ...core } = planSchema.parse(input);
|
|
66
|
+
const p = buildPlan(core);
|
|
67
|
+
if (planHash !== confirmed || p.planHash !== confirmed || p.origin !== normalizeSite(identity.origin) || p.userId !== identity.userId)
|
|
68
|
+
throw new CliError('CONFIRMATION_REQUIRED');
|
|
69
|
+
return p;
|
|
70
|
+
}
|
|
71
|
+
export function resolveRefs(v, results) {
|
|
72
|
+
if (!v || typeof v !== 'object')
|
|
73
|
+
return v;
|
|
74
|
+
if (Object.hasOwn(v, 'ref')) {
|
|
75
|
+
const r = refSchema.parse(v);
|
|
76
|
+
const source = results[r.ref];
|
|
77
|
+
const result = r.field === 'photoId' ? source?.photoIds?.[r.localCandidateId] : source?.[r.field];
|
|
78
|
+
if (!result)
|
|
79
|
+
throw new CliError('RESULT_UNKNOWN');
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
if (Array.isArray(v))
|
|
83
|
+
return v.map(x => resolveRefs(x, results));
|
|
84
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, resolveRefs(x, results)]));
|
|
85
|
+
}
|
|
86
|
+
export function actionRequest(item, results) {
|
|
87
|
+
const a = actionSchema.parse(resolveRefs(item, results));
|
|
88
|
+
const resource = a.action.startsWith('albums.') ? 'albums' : a.action.startsWith('timeline.') ? 'milestones' : a.action.startsWith('diary.') ? 'diaries' : 'imports';
|
|
89
|
+
const suffix = 'target' in a ? `/${encodeURIComponent(a.target)}` : '';
|
|
90
|
+
return { path: `/${resource}${suffix}${a.action === 'albums.photos' ? '/photos' : a.action === 'photos.cancel' ? '/cancel' : ''}`, method: a.action.endsWith('.update') || a.action === 'diary.edit' || a.action === 'diary.publish' ? 'PATCH' : 'POST', body: a.payload, ...('expectedVersion' in a ? { version: a.expectedVersion } : {}) };
|
|
91
|
+
}
|
|
92
|
+
export function textBlocks(text) {
|
|
93
|
+
return text.split(/\r?\n\s*\r?\n/).map(text => ({ type: 'paragraph', text }));
|
|
94
|
+
}
|
|
95
|
+
export function revisePlan(previous, input, sent) {
|
|
96
|
+
const next = buildPlan(input);
|
|
97
|
+
const mapping = new Map();
|
|
98
|
+
function remap(v) {
|
|
99
|
+
if (Array.isArray(v))
|
|
100
|
+
return v.map(remap);
|
|
101
|
+
if (!v || typeof v !== 'object')
|
|
102
|
+
return v;
|
|
103
|
+
return Object.fromEntries(Object.entries(v).map(([k, val]) => [k, (k === 'ref' || k === 'operationId') && typeof val === 'string' ? mapping.get(val) ?? val : remap(val)]));
|
|
104
|
+
}
|
|
105
|
+
const items = next.items.map(item => {
|
|
106
|
+
const revised = remap(item);
|
|
107
|
+
const old = previous.items.find(o => o.operationId === item.operationId);
|
|
108
|
+
if (old && canonicalJson(old) !== canonicalJson(revised)) {
|
|
109
|
+
if (sent.has(item.operationId))
|
|
110
|
+
throw new CliError('RESULT_UNKNOWN');
|
|
111
|
+
mapping.set(item.operationId, randomUUID());
|
|
112
|
+
revised.operationId = mapping.get(item.operationId);
|
|
113
|
+
}
|
|
114
|
+
return revised;
|
|
115
|
+
});
|
|
116
|
+
if (previous.origin !== next.origin || previous.userId !== next.userId || previous.items.some(i => sent.has(i.operationId) && !items.some(n => n.operationId === i.operationId)))
|
|
117
|
+
throw new CliError('RESULT_UNKNOWN');
|
|
118
|
+
return buildPlan({ schemaVersion: 1, origin: next.origin, userId: next.userId, items });
|
|
119
|
+
}
|
package/dist/scan.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Candidate, type Manifest } from './manifest.js';
|
|
2
|
+
export declare function imageMime(b: Buffer): Candidate['mimeType'] | undefined;
|
|
3
|
+
export declare function readPhoto(file: string, expected?: {
|
|
4
|
+
contentHash: string;
|
|
5
|
+
fileSize: number;
|
|
6
|
+
mimeType: string;
|
|
7
|
+
}, scope?: string): Promise<Buffer<ArrayBuffer>>;
|
|
8
|
+
export declare function imageDimensions(file: string): Promise<{
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function validateZipName(name: string): string;
|
|
13
|
+
export declare function zipEntries(file: string): Promise<string[]>;
|
|
14
|
+
type Issue = {
|
|
15
|
+
originalName: string;
|
|
16
|
+
relativePathHash: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
};
|
|
19
|
+
export type ScanResult = {
|
|
20
|
+
manifest: Manifest;
|
|
21
|
+
manifestHash: string;
|
|
22
|
+
files: Record<string, string>;
|
|
23
|
+
inputKind: 'DIRECTORY' | 'ZIP' | 'FILES';
|
|
24
|
+
unsupported: Issue[];
|
|
25
|
+
duplicates: Issue[];
|
|
26
|
+
stagingDirectory?: string;
|
|
27
|
+
};
|
|
28
|
+
export declare function scan(options: {
|
|
29
|
+
input?: string;
|
|
30
|
+
files?: string[];
|
|
31
|
+
stagingDir?: string;
|
|
32
|
+
}): Promise<ScanResult>;
|
|
33
|
+
export {};
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, readdir, realpath, open, mkdtemp, writeFile, access } from 'node:fs/promises';
|
|
3
|
+
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import exifr from 'exifr';
|
|
8
|
+
import { CliError, LIMITS, sha256, canonicalJson, checkBytes } from './core.js';
|
|
9
|
+
import { manifestSchema } from './manifest.js';
|
|
10
|
+
import { privateDirectory } from './storage.js';
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
const supported = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' };
|
|
13
|
+
export function imageMime(b) {
|
|
14
|
+
if (b.length >= 24 && b.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && b.subarray(-8, -4).toString() === 'IEND')
|
|
15
|
+
return 'image/png';
|
|
16
|
+
if (b.length >= 20 && b[0] === 0xff && b[1] === 0xd8 && b[b.length - 2] === 0xff && b[b.length - 1] === 0xd9) {
|
|
17
|
+
let i = 2, hasFrame = false;
|
|
18
|
+
while (i + 4 < b.length && b[i] === 0xff) {
|
|
19
|
+
const marker = b[i + 1], length = b.readUInt16BE(i + 2);
|
|
20
|
+
if (length < 2 || i + 2 + length > b.length)
|
|
21
|
+
break;
|
|
22
|
+
if ([0xc0, 0xc1, 0xc2].includes(marker) && length >= 8 && b.readUInt16BE(i + 5) && b.readUInt16BE(i + 7))
|
|
23
|
+
hasFrame = true;
|
|
24
|
+
if (marker === 0xda)
|
|
25
|
+
return hasFrame ? 'image/jpeg' : undefined;
|
|
26
|
+
i += length + 2;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (b.length >= 20 && b.subarray(0, 4).toString() === 'RIFF' && b.subarray(8, 12).toString() === 'WEBP' && b.readUInt32LE(4) + 8 === b.length && ['VP8 ', 'VP8L', 'VP8X'].includes(b.subarray(12, 16).toString()))
|
|
30
|
+
return 'image/webp';
|
|
31
|
+
}
|
|
32
|
+
export async function readPhoto(file, expected, scope) {
|
|
33
|
+
const actual = await realpath(file);
|
|
34
|
+
if (scope && !actual.startsWith(`${scope}/`))
|
|
35
|
+
throw new CliError('SOURCE_CHANGED');
|
|
36
|
+
const f = await open(actual, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
37
|
+
try {
|
|
38
|
+
const stat = await f.stat();
|
|
39
|
+
if (!stat.isFile() || stat.size < 1 || stat.size > LIMITS.maxFileBytes)
|
|
40
|
+
throw new CliError('SOURCE_CHANGED');
|
|
41
|
+
const data = Buffer.alloc(stat.size + 1);
|
|
42
|
+
let used = 0;
|
|
43
|
+
while (used < data.length) {
|
|
44
|
+
const { bytesRead } = await f.read(data, used, data.length - used, null);
|
|
45
|
+
if (!bytesRead)
|
|
46
|
+
break;
|
|
47
|
+
used += bytesRead;
|
|
48
|
+
}
|
|
49
|
+
const buffer = data.subarray(0, used);
|
|
50
|
+
if (used !== stat.size || !imageMime(buffer) || (expected && (expected.fileSize !== used || expected.contentHash !== sha256(buffer) || expected.mimeType !== imageMime(buffer))))
|
|
51
|
+
throw new CliError('SOURCE_CHANGED');
|
|
52
|
+
return buffer;
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
await f.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function imageDimensions(file) {
|
|
59
|
+
const { stdout } = await run('/usr/bin/sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', file], { timeout: 15000, maxBuffer: 65536 });
|
|
60
|
+
const width = Number(/^\s*pixelWidth:\s*(\d+)\s*$/m.exec(stdout)?.[1]);
|
|
61
|
+
const height = Number(/^\s*pixelHeight:\s*(\d+)\s*$/m.exec(stdout)?.[1]);
|
|
62
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width > 100000 || height > 100000)
|
|
63
|
+
throw new CliError('SOURCE_CHANGED');
|
|
64
|
+
return { width, height };
|
|
65
|
+
}
|
|
66
|
+
export function validateZipName(name) {
|
|
67
|
+
if (!name || name.startsWith('/') || /^[a-z]:/i.test(name) || /[\\\x00-\x1f\x7f*?\[\]]/.test(name) || name.split('/').some(p => p === '..' || p === '.' || p === '' && !name.endsWith('/')))
|
|
68
|
+
throw new CliError('INVALID_INPUT');
|
|
69
|
+
return name;
|
|
70
|
+
}
|
|
71
|
+
// Read the central directory before invoking unzip: external attributes carry symlink/type bits.
|
|
72
|
+
export async function zipEntries(file) {
|
|
73
|
+
const f = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
74
|
+
try {
|
|
75
|
+
const { size } = await f.stat();
|
|
76
|
+
if (size > LIMITS.maxFileBytes * LIMITS.maxCandidates || size < 22)
|
|
77
|
+
throw new CliError('INVALID_INPUT');
|
|
78
|
+
const tail = Buffer.alloc(Math.min(size, 65557));
|
|
79
|
+
await f.read(tail, 0, tail.length, size - tail.length);
|
|
80
|
+
let end = -1;
|
|
81
|
+
for (let i = tail.length - 22; i >= 0; i--)
|
|
82
|
+
if (tail.readUInt32LE(i) === 0x06054b50 && i + 22 + tail.readUInt16LE(i + 20) === tail.length) {
|
|
83
|
+
end = i;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
if (end < 0 || tail.readUInt16LE(end + 4) || tail.readUInt16LE(end + 6))
|
|
87
|
+
throw new CliError('INVALID_INPUT');
|
|
88
|
+
const count = tail.readUInt16LE(end + 10), length = tail.readUInt32LE(end + 12), offset = tail.readUInt32LE(end + 16);
|
|
89
|
+
if (count > 2000 || length > 2 * 1024 * 1024 || offset + length > size - 22 || count !== tail.readUInt16LE(end + 8))
|
|
90
|
+
throw new CliError('INVALID_INPUT');
|
|
91
|
+
const dir = Buffer.alloc(length);
|
|
92
|
+
await f.read(dir, 0, length, offset);
|
|
93
|
+
const names = [];
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
let cursor = 0;
|
|
96
|
+
let total = 0;
|
|
97
|
+
for (let i = 0; i < count; i++) {
|
|
98
|
+
if (cursor + 46 > length || dir.readUInt32LE(cursor) !== 0x02014b50)
|
|
99
|
+
throw new CliError('INVALID_INPUT');
|
|
100
|
+
const flags = dir.readUInt16LE(cursor + 8), size = dir.readUInt32LE(cursor + 24), n = dir.readUInt16LE(cursor + 28), extra = dir.readUInt16LE(cursor + 30), comment = dir.readUInt16LE(cursor + 32), mode = dir.readUInt32LE(cursor + 38) >>> 16;
|
|
101
|
+
if (flags & 1 || (mode & 0xf000) === 0xa000 || ((mode & 0xf000) && ![0x8000, 0x4000].includes(mode & 0xf000)) || cursor + 46 + n + extra + comment > length)
|
|
102
|
+
throw new CliError('INVALID_INPUT');
|
|
103
|
+
const name = validateZipName(new TextDecoder('utf-8', { fatal: true }).decode(dir.subarray(cursor + 46, cursor + 46 + n)));
|
|
104
|
+
if (seen.has(name))
|
|
105
|
+
throw new CliError('INVALID_INPUT');
|
|
106
|
+
seen.add(name);
|
|
107
|
+
if (!name.endsWith('/')) {
|
|
108
|
+
total += size;
|
|
109
|
+
names.push(name);
|
|
110
|
+
}
|
|
111
|
+
if (total > LIMITS.maxFileBytes * LIMITS.maxCandidates)
|
|
112
|
+
throw new CliError('PAYLOAD_TOO_LARGE');
|
|
113
|
+
cursor += 46 + n + extra + comment;
|
|
114
|
+
}
|
|
115
|
+
if (cursor !== length)
|
|
116
|
+
throw new CliError('INVALID_INPUT');
|
|
117
|
+
const { stdout } = await run('/usr/bin/unzip', ['-Z1', file], { maxBuffer: 2 * 1024 * 1024, timeout: 30000 });
|
|
118
|
+
if (canonicalJson(stdout.split('\n').filter(n => n && !n.endsWith('/'))) !== canonicalJson(names))
|
|
119
|
+
throw new CliError('INVALID_INPUT');
|
|
120
|
+
return names;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
await f.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export async function scan(options) {
|
|
127
|
+
if (!!options.input === !!options.files?.length)
|
|
128
|
+
throw new CliError('INVALID_INPUT');
|
|
129
|
+
try {
|
|
130
|
+
await access('/usr/bin/sips', constants.X_OK);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new CliError('TOOL_UNAVAILABLE');
|
|
134
|
+
}
|
|
135
|
+
const unsupported = [];
|
|
136
|
+
const duplicates = [];
|
|
137
|
+
const candidates = [];
|
|
138
|
+
const issue = (name, reason) => ({ originalName: basename(name), relativePathHash: sha256(name), reason });
|
|
139
|
+
let inspected = 0;
|
|
140
|
+
let total = 0;
|
|
141
|
+
async function accept(name, get) {
|
|
142
|
+
const expectedMime = supported[extname(name).toLowerCase()];
|
|
143
|
+
if (!expectedMime) {
|
|
144
|
+
unsupported.push(issue(name, /\.hei[cf]$/i.test(name) ? 'HEIC must be converted to JPEG, PNG or WebP.' : 'Unsupported format.'));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (++inspected > LIMITS.maxCandidates)
|
|
148
|
+
throw new CliError('PAYLOAD_TOO_LARGE');
|
|
149
|
+
let bytes, file;
|
|
150
|
+
try {
|
|
151
|
+
const data = await get();
|
|
152
|
+
bytes = data.bytes;
|
|
153
|
+
file = data.path;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
unsupported.push(issue(name, 'Unreadable, corrupt or oversized file.'));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
total += bytes.length;
|
|
160
|
+
if (total > LIMITS.maxCandidates * LIMITS.maxFileBytes)
|
|
161
|
+
throw new CliError('PAYLOAD_TOO_LARGE');
|
|
162
|
+
const mimeType = imageMime(bytes);
|
|
163
|
+
if (!mimeType || mimeType !== expectedMime || bytes.length > LIMITS.maxFileBytes) {
|
|
164
|
+
unsupported.push(issue(name, 'Corrupt or mismatched image.'));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
let dimensions;
|
|
168
|
+
try {
|
|
169
|
+
dimensions = await imageDimensions(file);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
unsupported.push(issue(name, 'Image dimensions could not be decoded by macOS sips.'));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
let metadata = {};
|
|
176
|
+
try {
|
|
177
|
+
const tags = await exifr.parse(bytes, { pick: ['DateTimeOriginal', 'CreateDate', 'Make', 'Model', 'latitude', 'longitude'] });
|
|
178
|
+
const takenAt = tags?.DateTimeOriginal ?? tags?.CreateDate;
|
|
179
|
+
const gps = await exifr.gps(bytes);
|
|
180
|
+
metadata = { ...(takenAt ? { takenAt: new Date(takenAt).toISOString() } : {}), ...(gps ? { gpsLatitude: gps.latitude, gpsLongitude: gps.longitude } : {}), ...(tags?.Make || tags?.Model ? { deviceModel: [tags.Make, tags.Model].filter(Boolean).join(' ').slice(0, 255) } : {}) };
|
|
181
|
+
}
|
|
182
|
+
catch { /* Images without EXIF still participate in rules-only grouping. */ }
|
|
183
|
+
candidates.push({ candidate: { contentHash: sha256(bytes), relativePathHash: sha256(name), originalName: basename(name), mimeType, fileSize: bytes.length, ...dimensions, ...metadata, groupKey: metadata.takenAt?.slice(0, 10) ?? (metadata.gpsLatitude !== undefined && metadata.gpsLongitude !== undefined ? `${metadata.gpsLatitude.toFixed(2)},${metadata.gpsLongitude.toFixed(2)}` : 'ungrouped'), recommendationScore: 0.8, recommendationReason: 'Rules only: supported image; no visual analysis.', status: 'SELECTED' }, path: file, source: name });
|
|
184
|
+
}
|
|
185
|
+
let inputKind = 'FILES', sourceLabel = 'Selected files', stagingDirectory;
|
|
186
|
+
if (options.input) {
|
|
187
|
+
const input = await realpath(resolve(options.input));
|
|
188
|
+
const stat = await lstat(input);
|
|
189
|
+
sourceLabel = basename(input);
|
|
190
|
+
if (stat.isDirectory()) {
|
|
191
|
+
inputKind = 'DIRECTORY';
|
|
192
|
+
let entries = 0;
|
|
193
|
+
async function walk(dir, depth = 0) {
|
|
194
|
+
if (depth > 64)
|
|
195
|
+
throw new CliError('INVALID_INPUT');
|
|
196
|
+
for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
197
|
+
if (++entries > 10000)
|
|
198
|
+
throw new CliError('PAYLOAD_TOO_LARGE');
|
|
199
|
+
const file = join(dir, entry.name), name = relative(input, file);
|
|
200
|
+
if (entry.isSymbolicLink()) {
|
|
201
|
+
unsupported.push(issue(name, 'Symlink skipped.'));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (entry.isDirectory())
|
|
205
|
+
await walk(file, depth + 1);
|
|
206
|
+
else if (entry.isFile())
|
|
207
|
+
await accept(name, async () => ({ bytes: await readPhoto(file, undefined, input), path: await realpath(file) }));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
await walk(input);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
if (extname(input).toLowerCase() !== '.zip')
|
|
214
|
+
throw new CliError('INVALID_INPUT');
|
|
215
|
+
try {
|
|
216
|
+
await access('/usr/bin/unzip', constants.X_OK);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
throw new CliError('TOOL_UNAVAILABLE');
|
|
220
|
+
}
|
|
221
|
+
inputKind = 'ZIP';
|
|
222
|
+
const names = await zipEntries(input);
|
|
223
|
+
const parent = options.stagingDir ? resolve(options.stagingDir) : await realpath(tmpdir());
|
|
224
|
+
if (options.stagingDir)
|
|
225
|
+
await privateDirectory(parent);
|
|
226
|
+
stagingDirectory = await mkdtemp(join(parent, 'yusijia-'));
|
|
227
|
+
for (const name of names)
|
|
228
|
+
await accept(name, async () => {
|
|
229
|
+
const { stdout } = await run('/usr/bin/unzip', ['-p', input, name], { encoding: 'buffer', maxBuffer: LIMITS.maxFileBytes, timeout: 30000 });
|
|
230
|
+
const bytes = Buffer.from(stdout), file = join(stagingDirectory, sha256(name));
|
|
231
|
+
await writeFile(file, bytes, { mode: 0o600, flag: 'wx' });
|
|
232
|
+
return { bytes, path: file };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
if (options.files.length > LIMITS.maxCandidates)
|
|
238
|
+
throw new CliError('PAYLOAD_TOO_LARGE');
|
|
239
|
+
for (const input of options.files) {
|
|
240
|
+
const file = resolve(input);
|
|
241
|
+
await accept(file, async () => ({ bytes: await readPhoto(file), path: await realpath(file) }));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function score(c) { return (c.candidate.takenAt ? 8 : 0) + (c.candidate.gpsLatitude !== undefined ? 4 : 0) + (c.candidate.deviceModel ? 2 : 0) + (/copy|副本|复件|duplicate/i.test(c.candidate.originalName) ? 0 : 1) - c.source.split('/').length * 0.01 - c.source.length * 0.0001; }
|
|
245
|
+
const unique = new Map();
|
|
246
|
+
for (const c of candidates.sort((a, b) => score(b) - score(a) || a.source.localeCompare(b.source))) {
|
|
247
|
+
if (unique.has(c.candidate.contentHash))
|
|
248
|
+
duplicates.push(issue(c.source, 'Same content; a better metadata/name representative was selected.'));
|
|
249
|
+
else
|
|
250
|
+
unique.set(c.candidate.contentHash, c);
|
|
251
|
+
}
|
|
252
|
+
const selected = [...unique.values()];
|
|
253
|
+
const manifestHash = sha256(canonicalJson({ schemaVersion: 1, sourceType: inputKind === 'ZIP' ? 'ZIP' : 'DIRECTORY', sourceLabel, candidates: selected.map(c => c.candidate) }));
|
|
254
|
+
const files = {};
|
|
255
|
+
const normalized = selected.map(c => { const localCandidateId = `imp_${sha256(`${manifestHash}:${c.candidate.contentHash}:${c.candidate.relativePathHash}`).slice(0, 40)}`; files[localCandidateId] = c.path; return { ...c.candidate, localCandidateId }; });
|
|
256
|
+
// An empty scan is still a useful report, but cannot be turned into an import plan.
|
|
257
|
+
const manifest = { schemaVersion: 1, sourceType: inputKind === 'ZIP' ? 'ZIP' : 'DIRECTORY', sourceLabel, candidates: normalized };
|
|
258
|
+
if (normalized.length)
|
|
259
|
+
manifestSchema.parse(manifest);
|
|
260
|
+
checkBytes({ manifest });
|
|
261
|
+
return { manifest, manifestHash, files, inputKind, unsupported, duplicates, ...(stagingDirectory ? { stagingDirectory } : {}) };
|
|
262
|
+
}
|
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
export declare const defaultSkillDirectory: () => string;
|
|
3
|
+
export declare function installSkill(directory?: string, stateDir?: string): Promise<{
|
|
4
|
+
directory: string;
|
|
5
|
+
cliVersion: string;
|
|
6
|
+
skillVersion: string;
|
|
7
|
+
}>;
|
|
8
|
+
export declare function updateSkills(stateDir?: string): Promise<{
|
|
9
|
+
ok: boolean;
|
|
10
|
+
cliVersion: string;
|
|
11
|
+
skills: {
|
|
12
|
+
directory: string;
|
|
13
|
+
ok: boolean;
|
|
14
|
+
cliVersion: string;
|
|
15
|
+
skillVersion?: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
}[];
|
|
18
|
+
}>;
|
|
19
|
+
export declare function updatePackage(root?: string, execute?: typeof execFile.__promisify__): Promise<unknown>;
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { readFile, readdir, mkdir, realpath } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { CliError, sha256, VERSION } from './core.js';
|
|
9
|
+
import { checkParents, configDir, privateDirectory, readPrivateJson, writePrivateJson } from './storage.js';
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const packageRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
12
|
+
const installsSchema = z.array(z.string());
|
|
13
|
+
const markerSchema = z.object({ version: z.string(), hash: z.string() }).strict();
|
|
14
|
+
export const defaultSkillDirectory = () => join(homedir(), '.agents', 'skills', 'family-content');
|
|
15
|
+
export async function installSkill(directory = defaultSkillDirectory(), stateDir = configDir()) {
|
|
16
|
+
const target = resolve(directory);
|
|
17
|
+
await checkParents(target);
|
|
18
|
+
const skill = (await readFile(join(packageRoot, 'skills/family-content/SKILL.md'), 'utf8')).replaceAll('{{CLI_VERSION}}', VERSION);
|
|
19
|
+
let entries = [];
|
|
20
|
+
try {
|
|
21
|
+
entries = await readdir(target);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
if (e.code !== 'ENOENT')
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
if (entries.length) {
|
|
28
|
+
try {
|
|
29
|
+
if (entries.some(e => !['SKILL.md', '.yusijia-skill.json'].includes(e)))
|
|
30
|
+
throw new CliError('SKILL_CONFLICT');
|
|
31
|
+
const marker = markerSchema.parse(await readPrivateJson(join(target, '.yusijia-skill.json')));
|
|
32
|
+
await checkParents(join(target, 'SKILL.md'));
|
|
33
|
+
if (sha256(await readFile(join(target, 'SKILL.md'))) !== marker.hash)
|
|
34
|
+
throw new CliError('SKILL_CONFLICT');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new CliError('SKILL_CONFLICT');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await privateDirectory(stateDir);
|
|
41
|
+
const registry = join(stateDir, 'skills.json');
|
|
42
|
+
let installed = [];
|
|
43
|
+
try {
|
|
44
|
+
installed = installsSchema.parse(await readPrivateJson(registry));
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
if (e.code !== 'ENOENT')
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
51
|
+
await privateDirectory(target);
|
|
52
|
+
// Reuse the private atomic writer, then emit plain Markdown rather than JSON.
|
|
53
|
+
await writeSkillFile(join(target, 'SKILL.md'), skill);
|
|
54
|
+
await writePrivateJson(join(target, '.yusijia-skill.json'), { version: VERSION, hash: sha256(skill) });
|
|
55
|
+
await writePrivateJson(registry, [...new Set([...installed, target])]);
|
|
56
|
+
return { directory: target, cliVersion: VERSION, skillVersion: VERSION };
|
|
57
|
+
}
|
|
58
|
+
async function writeSkillFile(file, text) {
|
|
59
|
+
const { writePrivateText } = await import('./storage.js');
|
|
60
|
+
await writePrivateText(file, text);
|
|
61
|
+
}
|
|
62
|
+
export async function updateSkills(stateDir = configDir()) {
|
|
63
|
+
let installed;
|
|
64
|
+
try {
|
|
65
|
+
installed = installsSchema.parse(await readPrivateJson(join(stateDir, 'skills.json')));
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
if (e.code === 'ENOENT')
|
|
69
|
+
return { ok: true, cliVersion: VERSION, skills: [] };
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
const skills = [];
|
|
73
|
+
for (const directory of installed) {
|
|
74
|
+
try {
|
|
75
|
+
skills.push({ ...(await installSkill(directory, stateDir)), ok: true });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
let skillVersion;
|
|
79
|
+
try {
|
|
80
|
+
skillVersion = markerSchema.parse(await readPrivateJson(join(directory, '.yusijia-skill.json'))).version;
|
|
81
|
+
}
|
|
82
|
+
catch { /* Unknown installations remain untouched. */ }
|
|
83
|
+
skills.push({ directory, ok: false, cliVersion: VERSION, skillVersion, error: 'SKILL_CONFLICT' });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { ok: skills.every(s => s.ok), cliVersion: VERSION, skills };
|
|
87
|
+
}
|
|
88
|
+
export async function updatePackage(root = packageRoot, execute = run) {
|
|
89
|
+
const manifest = z.object({ name: z.string().regex(/^(?:@[a-z0-9-]+\/)?[a-z0-9-]+$/), version: z.string().regex(/^\d+\.\d+\.\d+(?:-beta\.\d+)?$/), private: z.boolean().optional() }).parse(JSON.parse(await readFile(join(root, 'package.json'), 'utf8')));
|
|
90
|
+
if (manifest.private)
|
|
91
|
+
throw new CliError('NOT_PUBLISHED');
|
|
92
|
+
const channel = manifest.version.includes('-beta.') ? 'beta' : 'latest';
|
|
93
|
+
let globalRoot;
|
|
94
|
+
try {
|
|
95
|
+
globalRoot = (await execute('npm', ['root', '--global'], { timeout: 30000 })).stdout.toString().trim();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new CliError('UPDATE_FAILED');
|
|
99
|
+
}
|
|
100
|
+
const installedRoot = join(globalRoot, manifest.name);
|
|
101
|
+
try {
|
|
102
|
+
if (await realpath(root) !== await realpath(installedRoot))
|
|
103
|
+
throw new CliError('NOT_GLOBAL');
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw new CliError('NOT_GLOBAL');
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await execute('npm', ['install', '--global', `${manifest.name}@${channel}`, '--registry=https://registry.npmjs.org/', '--ignore-scripts', '--no-audit', '--no-fund'], { timeout: 300000, maxBuffer: 1024 * 1024 });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
throw new CliError('UPDATE_FAILED');
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const result = await execute(process.execPath, [join(installedRoot, 'dist/cli.js'), 'skills', 'update', '--json'], { timeout: 30000, maxBuffer: 1024 * 1024 });
|
|
116
|
+
return JSON.parse(result.stdout.toString());
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
const output = e.stdout;
|
|
120
|
+
if (output) {
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(output);
|
|
123
|
+
}
|
|
124
|
+
catch { /* Never print npm or subprocess diagnostics. */ }
|
|
125
|
+
}
|
|
126
|
+
return { ok: false, cliUpdated: true, error: 'Skill synchronization failed; run yusijia skills update.' };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const configDir: () => string;
|
|
3
|
+
export declare const MAX_PRIVATE_JSON_BYTES: number;
|
|
4
|
+
export declare function serializePrivateJson(value: unknown): string;
|
|
5
|
+
export declare function checkParents(file: string): Promise<void>;
|
|
6
|
+
export declare function privateDirectory(dir: string): Promise<void>;
|
|
7
|
+
export declare function readPrivateJson(file: string): Promise<unknown>;
|
|
8
|
+
export declare function readInputJson(file: string): Promise<unknown>;
|
|
9
|
+
export declare function writePrivateText(file: string, text: string): Promise<void>;
|
|
10
|
+
export declare function writePrivateJson(file: string, value: unknown): Promise<void>;
|
|
11
|
+
declare const credentialSchema: z.ZodObject<{
|
|
12
|
+
token: z.ZodString;
|
|
13
|
+
expiresAt: z.ZodISODateTime;
|
|
14
|
+
userId: z.ZodString;
|
|
15
|
+
}, z.core.$strict>;
|
|
16
|
+
export type Credential = z.infer<typeof credentialSchema>;
|
|
17
|
+
export declare class CredentialStore {
|
|
18
|
+
readonly directory: string;
|
|
19
|
+
constructor(directory?: string);
|
|
20
|
+
private read;
|
|
21
|
+
get(site: string): Promise<{
|
|
22
|
+
token: string;
|
|
23
|
+
expiresAt: string;
|
|
24
|
+
userId: string;
|
|
25
|
+
}>;
|
|
26
|
+
set(site: string, credential: Credential): Promise<void>;
|
|
27
|
+
remove(site: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export {};
|