@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/storage.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve, parse } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { CliError, normalizeSite } from './core.js';
|
|
8
|
+
export const configDir = () => join(homedir(), '.config', 'yusijia');
|
|
9
|
+
export const MAX_PRIVATE_JSON_BYTES = 32 * 1024 * 1024;
|
|
10
|
+
function checkPrivateBytes(bytes) {
|
|
11
|
+
if (bytes > MAX_PRIVATE_JSON_BYTES)
|
|
12
|
+
throw new CliError('LOCAL_STATE_TOO_LARGE');
|
|
13
|
+
}
|
|
14
|
+
export function serializePrivateJson(value) {
|
|
15
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
16
|
+
checkPrivateBytes(Buffer.byteLength(text, 'utf8'));
|
|
17
|
+
return text;
|
|
18
|
+
}
|
|
19
|
+
const missing = (e) => e.code === 'ENOENT';
|
|
20
|
+
export async function checkParents(file) {
|
|
21
|
+
const absolute = resolve(file);
|
|
22
|
+
let current = parse(absolute).root;
|
|
23
|
+
for (const part of absolute.slice(current.length).split('/').filter(Boolean)) {
|
|
24
|
+
current = join(current, part);
|
|
25
|
+
try {
|
|
26
|
+
if ((await lstat(current)).isSymbolicLink())
|
|
27
|
+
throw new CliError('UNSAFE_PATH');
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
if (!missing(e))
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function checkOwner(s, mode) {
|
|
36
|
+
if (s.uid !== process.getuid?.() || (mode !== undefined && (s.mode & 0o777) !== mode))
|
|
37
|
+
throw new CliError('UNSAFE_PATH');
|
|
38
|
+
}
|
|
39
|
+
export async function privateDirectory(dir) {
|
|
40
|
+
await checkParents(dir);
|
|
41
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
42
|
+
const s = await lstat(dir);
|
|
43
|
+
if (!s.isDirectory() || s.isSymbolicLink())
|
|
44
|
+
throw new CliError('UNSAFE_PATH');
|
|
45
|
+
checkOwner(s, 0o700);
|
|
46
|
+
}
|
|
47
|
+
export async function readPrivateJson(file) {
|
|
48
|
+
await checkParents(file);
|
|
49
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
50
|
+
try {
|
|
51
|
+
const s = await handle.stat();
|
|
52
|
+
checkOwner(s, 0o600);
|
|
53
|
+
if (!s.isFile())
|
|
54
|
+
throw new CliError('UNSAFE_PATH');
|
|
55
|
+
checkPrivateBytes(s.size);
|
|
56
|
+
const text = await handle.readFile('utf8');
|
|
57
|
+
checkPrivateBytes(Buffer.byteLength(text, 'utf8'));
|
|
58
|
+
return JSON.parse(text);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await handle.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function readInputJson(file) {
|
|
65
|
+
await checkParents(file);
|
|
66
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
67
|
+
try {
|
|
68
|
+
const s = await handle.stat();
|
|
69
|
+
if (!s.isFile() || s.size > MAX_PRIVATE_JSON_BYTES)
|
|
70
|
+
throw new CliError('PAYLOAD_TOO_LARGE');
|
|
71
|
+
return JSON.parse(await handle.readFile('utf8'));
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await handle.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export async function writePrivateText(file, text) {
|
|
78
|
+
checkPrivateBytes(Buffer.byteLength(text, 'utf8'));
|
|
79
|
+
await checkParents(file);
|
|
80
|
+
const parent = dirname(resolve(file));
|
|
81
|
+
const ps = await lstat(parent);
|
|
82
|
+
checkOwner(ps);
|
|
83
|
+
if (!ps.isDirectory() || (ps.mode & 0o022))
|
|
84
|
+
throw new CliError('UNSAFE_PATH');
|
|
85
|
+
try {
|
|
86
|
+
const s = await lstat(file);
|
|
87
|
+
if (!s.isFile())
|
|
88
|
+
throw new CliError('UNSAFE_PATH');
|
|
89
|
+
checkOwner(s, 0o600);
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
if (!missing(e))
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
const temp = join(parent, `.${randomUUID()}.tmp`);
|
|
96
|
+
const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
97
|
+
try {
|
|
98
|
+
await handle.writeFile(text);
|
|
99
|
+
await handle.sync();
|
|
100
|
+
await handle.close();
|
|
101
|
+
await checkParents(file);
|
|
102
|
+
await rename(temp, file);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
await handle.close().catch(() => { });
|
|
106
|
+
await unlink(temp).catch(() => { });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export async function writePrivateJson(file, value) { return writePrivateText(file, serializePrivateJson(value)); }
|
|
110
|
+
const credentialSchema = z.object({ token: z.string().min(1), expiresAt: z.iso.datetime(), userId: z.string().min(1) }).strict();
|
|
111
|
+
const credentialsSchema = z.record(z.string(), credentialSchema);
|
|
112
|
+
export class CredentialStore {
|
|
113
|
+
directory;
|
|
114
|
+
constructor(directory = configDir()) {
|
|
115
|
+
this.directory = directory;
|
|
116
|
+
}
|
|
117
|
+
async read() {
|
|
118
|
+
await privateDirectory(this.directory);
|
|
119
|
+
try {
|
|
120
|
+
return credentialsSchema.parse(await readPrivateJson(join(this.directory, 'credentials.json')));
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
if (missing(e))
|
|
124
|
+
return {};
|
|
125
|
+
throw e;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async get(site) { return (await this.read())[normalizeSite(site)]; }
|
|
129
|
+
async set(site, credential) {
|
|
130
|
+
const all = await this.read();
|
|
131
|
+
all[normalizeSite(site)] = credentialSchema.parse(credential);
|
|
132
|
+
await writePrivateJson(join(this.directory, 'credentials.json'), all);
|
|
133
|
+
}
|
|
134
|
+
async remove(site) {
|
|
135
|
+
const all = await this.read();
|
|
136
|
+
delete all[normalizeSite(site)];
|
|
137
|
+
await writePrivateJson(join(this.directory, 'credentials.json'), all);
|
|
138
|
+
}
|
|
139
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yuchaocheng/yusijia",
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"description": "Family content CLI and Agent Skill for photos, albums, timelines and diaries",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"publishConfig": { "access": "public", "tag": "beta", "registry": "https://registry.npmjs.org/" },
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": { "yusijia": "dist/cli.js" },
|
|
9
|
+
"engines": { "node": ">=20.20.2" },
|
|
10
|
+
"os": ["darwin"],
|
|
11
|
+
"cpu": ["arm64", "x64"],
|
|
12
|
+
"files": ["dist", "skills", "README.md"],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
16
|
+
"lint": "tsc -p tsconfig.json --noEmit --noUnusedLocals --noUnusedParameters",
|
|
17
|
+
"test": "vitest run --config vitest.config.ts",
|
|
18
|
+
"test:package": "node scripts/package-test.mjs",
|
|
19
|
+
"prepack": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": { "exifr": "^7.1.3", "zod": "^4.3.6" },
|
|
22
|
+
"devDependencies": { "@types/node": "^20", "typescript": "^5", "vitest": "^4.1.2" }
|
|
23
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: family-content
|
|
3
|
+
description: Use yusijia to prepare explicitly supplied photos and query or propose confirmed family albums, growth timeline events and diary changes.
|
|
4
|
+
metadata:
|
|
5
|
+
cli-version: '{{CLI_VERSION}}'
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Family Content
|
|
9
|
+
|
|
10
|
+
Use the installed `yusijia` command. Do not import website code, access a database,
|
|
11
|
+
read credentials.json, request passwords/tokens, or copy browser cookies. This Skill
|
|
12
|
+
contains instructions, not family data. Check `yusijia --version` and `yusijia --help`.
|
|
13
|
+
The installed Skill and CLI should have the same version.
|
|
14
|
+
|
|
15
|
+
## Identity
|
|
16
|
+
|
|
17
|
+
`yusijia auth login --json` opens the site's authorization page. The user signs in
|
|
18
|
+
and approves there. `auth status --json` shows the bound account and expiry. Default
|
|
19
|
+
site is https://yusijia.me; use explicit `--site` for a different HTTPS site or local
|
|
20
|
+
development loopback. State the site/account before any content plan. `auth logout`
|
|
21
|
+
revokes then removes this site's credential. A failure is not successful revocation.
|
|
22
|
+
The credential lasts 30 days and is plaintext in an owner-only file, not encrypted;
|
|
23
|
+
same-user processes, root and copied files remain a risk.
|
|
24
|
+
|
|
25
|
+
## Read And Prepare
|
|
26
|
+
|
|
27
|
+
- Query only relevant bounded pages: `photos|albums|timeline|diary list --limit 30
|
|
28
|
+
--cursor <opaque-cursor> --json`; `get --id <id> --json` for detail.
|
|
29
|
+
- Photo URLs are omitted by default. Only request `photos get --id <id>
|
|
30
|
+
--include-url --json` when needed to inspect an image; do not broadly print, share,
|
|
31
|
+
or retain signed URLs in plans, progress or long-lived documents.
|
|
32
|
+
- Scan only paths supplied by the user: `photos scan --input <directory-or-zip>
|
|
33
|
+
--out <scan.json> --json`, or repeat `--files <file>` for an explicit set.
|
|
34
|
+
- Show unsupported, corrupt, oversized and duplicate reports. HEIC must be converted
|
|
35
|
+
first. Do not imply rule-based grouping is visual inspection, or that old photos
|
|
36
|
+
without hashes can all be deduplicated. Never scan the whole disk or call external
|
|
37
|
+
vision services by default. Keep generated plans/scans private.
|
|
38
|
+
|
|
39
|
+
## Preview, Confirm, Execute
|
|
40
|
+
|
|
41
|
+
Business commands only generate local plans. They do not upload or save drafts.
|
|
42
|
+
Build proposals with `albums create/update/add/remove`, `timeline create/update`,
|
|
43
|
+
`diary create/edit/publish`, `photos import/cancel`, or combine typed actions using
|
|
44
|
+
`plan --input <draft.json> --out <plan.json>`. Use `plan show --plan <plan.json> --json`.
|
|
45
|
+
Read `yusijia --help` and the package README for payload examples.
|
|
46
|
+
|
|
47
|
+
To import and associate photos under one confirmation, put `photos.import` before
|
|
48
|
+
`albums.photos` in the same plan and use photo references in its `add` array:
|
|
49
|
+
`{ref: "<import-operation-uuid>", field: "photoId", localCandidateId: "<selected-candidate>"}`.
|
|
50
|
+
References resolve only after the prior import succeeds; never invent a photo ID.
|
|
51
|
+
In a combined plan, `albums.photos` requires both `payload.add` and
|
|
52
|
+
`payload.remove`, even when one is empty: `{ "add": [<photo reference>],
|
|
53
|
+
"remove": [] }`. Include the album `target` and its `expectedVersion` (literal
|
|
54
|
+
values or references to a prior album action). For a standalone association,
|
|
55
|
+
prefer `albums add/remove --id ... --photo-id ... --out ...`.
|
|
56
|
+
|
|
57
|
+
Before execution show the site, account, every target and expected version, selected
|
|
58
|
+
photos including GPS/time and create/reuse expectations, album associations, event
|
|
59
|
+
date/title/description/photos, and complete diary text plus DRAFT/PUBLISHED status.
|
|
60
|
+
Ask the user to confirm that exact plan. One explicit confirmation may cover the
|
|
61
|
+
whole plan. A saved draft is also a remote write requiring confirmation.
|
|
62
|
+
|
|
63
|
+
Only after confirmation run `execute --plan <plan.json> --confirm-hash <planHash>
|
|
64
|
+
--json`. Never supply this flag before consent or treat possession of a hash as proof
|
|
65
|
+
of human intent. Changes to content, target, version or source bytes invalidate the
|
|
66
|
+
old confirmation. Use `plan --previous <old-plan.json> --input <revised.json> --out
|
|
67
|
+
<new-plan.json>` for edits: changed unsent actions get new IDs. Completed or uncertain
|
|
68
|
+
actions must not be rewritten; use new explicit update actions after resolving them.
|
|
69
|
+
|
|
70
|
+
Diary create blocks are `{type,text,level?}`, not raw BlockNote JSON. Editing is
|
|
71
|
+
`replaceText` by blockId, `append` typed text blocks, or an explicit status change.
|
|
72
|
+
Never flatten an existing rich document or remove images/tables without consent.
|
|
73
|
+
Unsupported edits must stop and leave the website editor as the alternative.
|
|
74
|
+
Timeline allows at most three photos. Album remove only removes associations.
|
|
75
|
+
|
|
76
|
+
## Recovery
|
|
77
|
+
|
|
78
|
+
Partial completion is a nonzero exit, not an all-or-nothing rollback. Report each
|
|
79
|
+
success and its ID, each failed/unknown item and skipped dependency. Preserve the
|
|
80
|
+
plan, progress and operation IDs. Resume using the same `execute` command, or
|
|
81
|
+
`photos resume --plan ... --confirm-hash ...`; receipt lookup precedes replay of
|
|
82
|
+
uncertain requests. A 404 receipt does not prove nothing committed. Do not change
|
|
83
|
+
IDs or silently reduce selection to bypass a failure. Publishing starts only after
|
|
84
|
+
all selected candidates are verified. Cancellation is a separate confirmed plan;
|
|
85
|
+
already published photos remain. Do not delete formal content as compensation.
|
|
86
|
+
|
|
87
|
+
## Installation And Updates
|
|
88
|
+
|
|
89
|
+
`skills install [--dir <exact-skill-directory>]` defaults to
|
|
90
|
+
`~/.agents/skills/family-content`; it does not configure the host or other Skills.
|
|
91
|
+
`skills update` synchronizes registered installations without overwriting edits.
|
|
92
|
+
`update` uses public npm for this global package, then the new CLI synchronizes
|
|
93
|
+
Skills. Do not run sudo, read secret files, or claim success on a Skill conflict.
|
|
94
|
+
The private placeholder package cannot update remotely until publication preflight.
|