@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.
@@ -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.