@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/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Family CLI
|
|
2
|
+
|
|
3
|
+
Standalone compiled ESM CLI and bundled `family-content` Skill for macOS arm64/x64,
|
|
4
|
+
Node >=20.20.2. Uses Node built-ins, public `exifr` and `zod`. No database or website
|
|
5
|
+
source dependency. Photo scans use bounded macOS `sips` width/height probes in
|
|
6
|
+
addition to byte-format validation. Directory and explicit-file scans do not require `unzip`; zip
|
|
7
|
+
scans require `/usr/bin/unzip` and fail clearly when unavailable.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
The package is `@yuchaocheng/yusijia`. This is a beta release for acceptance testing,
|
|
12
|
+
not a stable release. The hosted website's new Agent API is not yet released;
|
|
13
|
+
use an explicitly supplied compatible development site for this beta. Installing
|
|
14
|
+
the beta does not deploy a website or automatically switch the default site.
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install --global @yuchaocheng/yusijia@beta --registry=https://registry.npmjs.org/
|
|
18
|
+
yusijia --help
|
|
19
|
+
yusijia skills install
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Read the installed `family-content` Skill in your Agent. Beta versions of
|
|
23
|
+
`yusijia update` stay on the `beta` tag; stable versions use `latest`. Updates never
|
|
24
|
+
fall back between channels. No stable `latest` tag is published with this beta.
|
|
25
|
+
|
|
26
|
+
Build and test a local tarball (not a release):
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pnpm -C family-cli build
|
|
30
|
+
npm pack ./family-cli --pack-destination ./family-cli
|
|
31
|
+
npm install --global /absolute/path/yuchaocheng-yusijia-0.1.0-beta.1.tgz
|
|
32
|
+
yusijia --help
|
|
33
|
+
yusijia skills install
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
No sudo is used by this CLI. A local install also works through its
|
|
37
|
+
`node_modules/.bin/yusijia`. After release, global update explicitly uses public
|
|
38
|
+
npm and invokes the newly installed CLI to synchronize registered Skills. Local
|
|
39
|
+
installations must use their original install method. Modified/unknown Skills are
|
|
40
|
+
never overwritten; a CLI-only update with Skill conflicts is reported as partial.
|
|
41
|
+
Host discovery of the installed Skill still needs acceptance in the chosen Agent.
|
|
42
|
+
|
|
43
|
+
## Authorization And Reads
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
yusijia auth login
|
|
47
|
+
yusijia auth status --json
|
|
48
|
+
yusijia photos list --limit 30 --json
|
|
49
|
+
yusijia photos get --id <photo-id> --include-url --json
|
|
50
|
+
yusijia albums get --id <album-id> --json
|
|
51
|
+
yusijia timeline list --cursor <opaque-cursor> --json
|
|
52
|
+
yusijia diary get --id <diary-id> --json
|
|
53
|
+
yusijia auth logout
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Photo URLs are absent by default. Only explicit `photos get --include-url` asks for
|
|
57
|
+
and displays a short-lived signed image URL. Do not broadly print, share, or persist
|
|
58
|
+
these URLs in plans, progress or long-lived documents.
|
|
59
|
+
|
|
60
|
+
Default site: `https://yusijia.me`. `--site` accepts an HTTPS origin or explicit
|
|
61
|
+
development loopback HTTP origin. Credentials are origin-isolated in
|
|
62
|
+
`~/.config/yusijia/credentials.json`: plaintext, directory 0700/file 0600, owner and
|
|
63
|
+
symlink checks and atomic writes. This is not encryption and does not protect from
|
|
64
|
+
same-user processes, root or copied files. Login uses a five-minute loopback PKCE
|
|
65
|
+
callback; valid credentials last 30 days and are not silently replaced. Passwords,
|
|
66
|
+
codes, tokens, signed upload forms and server diagnostics are not printed. Failed
|
|
67
|
+
logout keeps the local credential and explicitly does not claim server revocation.
|
|
68
|
+
If `/me` explicitly reports an invalid or expired credential, login opens a fresh
|
|
69
|
+
authorization flow and replaces the file only after successful exchange. Network
|
|
70
|
+
errors and account/permission refusals leave the credential unchanged.
|
|
71
|
+
|
|
72
|
+
## Prepare And Confirm
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
yusijia photos scan --input /absolute/photos --out ./scan.json
|
|
76
|
+
yusijia photos scan --files /absolute/a.jpg --files /absolute/b.png --out ./scan.json
|
|
77
|
+
yusijia photos import --input ./scan.json --out ./plan.json
|
|
78
|
+
yusijia plan show --plan ./plan.json --json
|
|
79
|
+
# Only after the user confirms the displayed site, account and all content:
|
|
80
|
+
yusijia execute --plan ./plan.json --confirm-hash <planHash> --json
|
|
81
|
+
yusijia photos resume --plan ./plan.json --confirm-hash <same-planHash> --json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Only supplied paths are scanned. JPEG/PNG/WebP, 15 MiB/file and 200 candidates;
|
|
85
|
+
duplicates are folded with deterministic metadata/name preference. Unsupported,
|
|
86
|
+
damaged, oversized and directory symlink files are reported. Explicitly supplied
|
|
87
|
+
material paths are resolved with `realpath`, including macOS `/tmp` and `/var`
|
|
88
|
+
aliases; directory walking never follows child symlinks. HEIC requires prior conversion.
|
|
89
|
+
Grouping is metadata/rules only, not visual interpretation. Historical photos
|
|
90
|
+
without hashes cannot be fully deduplicated. Zip entries are validated before
|
|
91
|
+
bounded stdout extraction to 0700 task directories and 0600 hashed file names.
|
|
92
|
+
Unsafe names, duplicate paths and symlink entries reject the archive. Scan output
|
|
93
|
+
reports the staging directory; retain it for pending uploads and remove it manually
|
|
94
|
+
when no longer needed. No caller-supplied directory is recursively deleted.
|
|
95
|
+
|
|
96
|
+
Plans/scans/progress are 0600; choose an existing owner-writable, non-group-writable
|
|
97
|
+
parent directory. Scan diagnostics and local file paths never enter the manifest
|
|
98
|
+
sent to the site. Explicit file sets use `sourceType: DIRECTORY` and local
|
|
99
|
+
`inputKind: FILES`. Unused legacy suggestions and arbitrary metadata are rejected.
|
|
100
|
+
|
|
101
|
+
`photos import` defaults to an explicitly previewed new-photo expectation for each
|
|
102
|
+
selected image. To confirm reuse supply `--expectations expectations.json`, keyed
|
|
103
|
+
by localCandidateId with `{ "mode": "reuse", "photoId": "...", "version": "..." }`
|
|
104
|
+
(or `{ "mode": "create" }`). Existing photos are not silently overwritten or
|
|
105
|
+
restored. Conflicting duplicate metadata requires a fresh preview.
|
|
106
|
+
|
|
107
|
+
## Independent Content Plans
|
|
108
|
+
|
|
109
|
+
All business commands generate plans, never write directly. `--input` is a JSON
|
|
110
|
+
business payload; edit commands take `--id` and use a read-only query to capture the
|
|
111
|
+
current version unless `--expected-version` is supplied. Every generated plan is
|
|
112
|
+
shown before confirmation.
|
|
113
|
+
|
|
114
|
+
| Commands | Payload |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `albums create/update` | `{ "name": "Family", "description": "..." }` (update fields optional) |
|
|
117
|
+
| `albums add/remove` | Repeat `--photo-id`; only associations change |
|
|
118
|
+
| `timeline create/update` | `{ "date": "2026-09-30", "title": "...", "description": "...", "photoIds": [] }` (max 3 photos) |
|
|
119
|
+
| `diary create` | `{ "blocks": [{ "type": "paragraph", "text": "..." }], "status": "DRAFT" }`; `--text` also supported |
|
|
120
|
+
| `diary edit` | `{ "edits": [{ "type": "replaceText", "blockId": "...", "text": "..." }] }` or `{ "type": "append", "blocks": [...] }` edits |
|
|
121
|
+
| `diary publish` | Generates explicit `status: PUBLISHED`; does not flatten existing rich content |
|
|
122
|
+
| `photos cancel` | `--id <batch-id>`; already published photos remain |
|
|
123
|
+
|
|
124
|
+
New diary blocks support paragraph/heading/bulletListItem/numberedListItem and
|
|
125
|
+
optional `id`; only headings allow `level` 1..3. At most 500 blocks and 100000 text
|
|
126
|
+
characters. Server edits preserve untargeted rich content; unsupported edits fail
|
|
127
|
+
without overwriting it. Saving a draft is a write and requires confirmation too.
|
|
128
|
+
|
|
129
|
+
Combine actions using `plan --input draft.json --out plan.json`. Draft shape:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"items": [
|
|
134
|
+
{ "operationId": "11111111-1111-4111-8111-111111111111", "action": "albums.create", "payload": { "name": "Family" } },
|
|
135
|
+
{ "operationId": "22222222-2222-4222-8222-222222222222", "action": "albums.update", "target": { "ref": "11111111-1111-4111-8111-111111111111", "field": "id" }, "expectedVersion": { "ref": "11111111-1111-4111-8111-111111111111", "field": "version" }, "payload": { "description": "Shared moments" } }
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Typed actions are `albums.create/update/photos`, `timeline.create/update`,
|
|
141
|
+
`diary.create/edit/publish`, `photos.import/cancel`. References may point only to
|
|
142
|
+
prior successful actions: fields `id`, `version`, or
|
|
143
|
+
`{ "ref": "<import-operation-uuid>", "field": "photoId", "localCandidateId": "<candidate-id>" }`
|
|
144
|
+
for a selected photo returned by a prior import. Such references let the same
|
|
145
|
+
confirmed plan import photos and associate them with an album without a second
|
|
146
|
+
confirmation. Photos import includes a local `files` map
|
|
147
|
+
and `expectations` map outside its HTTP `payload: {manifest}`. At most 500 plan
|
|
148
|
+
items and 2 MiB serialized UTF-8 bytes per HTTP request; never auto-split a plan.
|
|
149
|
+
|
|
150
|
+
For `albums.photos` in a combined plan, both `payload.add` and `payload.remove`
|
|
151
|
+
are required, including an empty array for the unused direction. For example:
|
|
152
|
+
`{ "add": ["<photo-id>"], "remove": [] }`. Set `target` to the album ID and
|
|
153
|
+
`expectedVersion` to its version, or use references to a prior album action.
|
|
154
|
+
Standalone `albums add/remove` commands generate these fields for you.
|
|
155
|
+
|
|
156
|
+
## Recovery And Protocol
|
|
157
|
+
|
|
158
|
+
Canonical sorted-key JSON SHA-256 binds the complete plan to origin and userId.
|
|
159
|
+
`plan --previous old.json --input revised.json --out new.json` assigns new IDs to
|
|
160
|
+
changed unsent actions. Successful/uncertain actions cannot be edited in place;
|
|
161
|
+
resolve them with the original ID, then propose new updates. Source bytes are
|
|
162
|
+
rechecked before uploading. Server independently enforces live permissions and
|
|
163
|
+
target versions; a hash flag is not proof of actual human intent.
|
|
164
|
+
|
|
165
|
+
Execution preserves per-item outcomes in origin/account-scoped private progress.
|
|
166
|
+
Unknown and failed requests remain frozen there. Successful entries retain their
|
|
167
|
+
operation IDs, fingerprints, request hashes and result references, not request
|
|
168
|
+
bodies; readable legacy entries are compacted on the next execution. Private JSON
|
|
169
|
+
reads and writes share a 32 MiB UTF-8 limit, including formatting and the final
|
|
170
|
+
newline. A pending request that would exceed it is rejected before remote writing,
|
|
171
|
+
without deleting earlier operation IDs or replacing the existing readable file.
|
|
172
|
+
An uncertain write is queried at
|
|
173
|
+
`GET /api/agent/v1/operations/:operationId`; 404 permits only identical same-ID
|
|
174
|
+
replay, not a claim of rollback. Dependent failures are skipped; independent actions
|
|
175
|
+
continue. Partial/unknown/error results exit nonzero. No cross-content rollback is
|
|
176
|
+
claimed and no formal objects are deleted as compensation.
|
|
177
|
+
|
|
178
|
+
API root `/api/agent/v1`; success `{ok:true,data}` and error
|
|
179
|
+
`{ok:false,error:{code,message,retryable}}`. `/meta` is checked before authenticated
|
|
180
|
+
requests. Writes carry `Idempotency-Key`, edits also `If-Match`. Authentication is
|
|
181
|
+
Bearer only and no redirects are followed. Website timeouts 30s; OSS POST 120s.
|
|
182
|
+
OSS receives only signed fields and final multipart file, never the website token.
|
|
183
|
+
Upload forms never enter progress or output. Real browser/OSS acceptance remains
|
|
184
|
+
separate from mocked tests and isolated package installation.
|
|
185
|
+
|
|
186
|
+
## Development Checks
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
pnpm -C family-cli test
|
|
190
|
+
pnpm -C family-cli typecheck
|
|
191
|
+
pnpm -C family-cli lint
|
|
192
|
+
pnpm -C family-cli build
|
|
193
|
+
pnpm -C family-cli test:package
|
|
194
|
+
```
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ApiClient } from './http.js';
|
|
2
|
+
import { CredentialStore } from './storage.js';
|
|
3
|
+
export declare function createCallback(timeout?: number): Promise<{
|
|
4
|
+
state: string;
|
|
5
|
+
verifier: string;
|
|
6
|
+
challenge: string;
|
|
7
|
+
redirectUri: string;
|
|
8
|
+
result: Promise<{
|
|
9
|
+
code: string;
|
|
10
|
+
}>;
|
|
11
|
+
cancel: () => void;
|
|
12
|
+
}>;
|
|
13
|
+
export declare function login(api: ApiClient, store?: CredentialStore, browser?: (url: string) => Promise<void>): Promise<Record<string, unknown>>;
|
|
14
|
+
export declare function status(api: ApiClient): Promise<{
|
|
15
|
+
id: string;
|
|
16
|
+
email: string;
|
|
17
|
+
name: string | null;
|
|
18
|
+
role: "FAMILY" | "ADMIN";
|
|
19
|
+
expiresAt: string;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function logout(api: ApiClient, store?: CredentialStore): Promise<{
|
|
22
|
+
revoked: boolean;
|
|
23
|
+
localRemoved: boolean;
|
|
24
|
+
}>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { CliError } from './core.js';
|
|
7
|
+
import { meSchema, userSchema } from './http.js';
|
|
8
|
+
import { CredentialStore } from './storage.js';
|
|
9
|
+
export async function createCallback(timeout = 300000) {
|
|
10
|
+
const state = randomBytes(32).toString('base64url');
|
|
11
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
12
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
13
|
+
let finish;
|
|
14
|
+
let fail;
|
|
15
|
+
const result = new Promise((resolve, reject) => { finish = resolve; fail = reject; });
|
|
16
|
+
// Attach a handler immediately; browser startup can outlive a denial/timeout.
|
|
17
|
+
void result.catch(() => { });
|
|
18
|
+
let done = false;
|
|
19
|
+
const server = createServer((req, res) => {
|
|
20
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
21
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
22
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
23
|
+
const u = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
24
|
+
if (u.pathname !== '/callback') {
|
|
25
|
+
res.writeHead(404).end('Not found');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const supplied = u.searchParams.get('state') ?? '';
|
|
29
|
+
const keys = [...u.searchParams.keys()];
|
|
30
|
+
const valid = req.method === 'GET' && keys.length === 2 && new Set(keys).size === 2 && keys.every(k => ['state', 'code', 'error'].includes(k)) && Buffer.byteLength(supplied) === Buffer.byteLength(state) && timingSafeEqual(Buffer.from(supplied), Buffer.from(state)) && !!(u.searchParams.get('code') || u.searchParams.get('error'));
|
|
31
|
+
if (!valid || done) {
|
|
32
|
+
res.writeHead(400).end('Invalid callback');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
done = true;
|
|
36
|
+
res.end('Authorization finished. You may close this window.');
|
|
37
|
+
close();
|
|
38
|
+
if (u.searchParams.has('error'))
|
|
39
|
+
fail(new CliError('AUTH_DENIED'));
|
|
40
|
+
else
|
|
41
|
+
finish({ code: u.searchParams.get('code') });
|
|
42
|
+
});
|
|
43
|
+
let timer;
|
|
44
|
+
function close() { if (timer)
|
|
45
|
+
clearTimeout(timer); server.close(); server.closeIdleConnections(); }
|
|
46
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
47
|
+
const address = server.address();
|
|
48
|
+
if (!address || typeof address === 'string')
|
|
49
|
+
throw new CliError('INTERNAL_ERROR');
|
|
50
|
+
timer = setTimeout(() => { done = true; fail(new CliError('AUTH_TIMEOUT')); close(); }, timeout);
|
|
51
|
+
return { state, verifier, challenge, redirectUri: `http://127.0.0.1:${address.port}/callback`, result, cancel: () => { done = true; fail(new CliError('AUTH_DENIED')); close(); } };
|
|
52
|
+
}
|
|
53
|
+
const openBrowser = async (url) => { await promisify(execFile)('/usr/bin/open', [url]); };
|
|
54
|
+
export async function login(api, store = new CredentialStore(), browser = openBrowser) {
|
|
55
|
+
const old = await store.get(api.origin);
|
|
56
|
+
if (old && Date.parse(old.expiresAt) > Date.now()) {
|
|
57
|
+
api.token = old.token;
|
|
58
|
+
try {
|
|
59
|
+
return await api.request('/me');
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
if (!(e instanceof CliError) || e.status === 403 || !['INVALID_CREDENTIAL', 'CREDENTIAL_EXPIRED'].includes(e.code))
|
|
63
|
+
throw e;
|
|
64
|
+
// Keep the old file until the new authorization has been exchanged and saved.
|
|
65
|
+
api.token = undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
await api.negotiate();
|
|
69
|
+
const c = await createCallback();
|
|
70
|
+
const url = new URL('/agent/authorize', api.origin);
|
|
71
|
+
url.search = new URLSearchParams({ state: c.state, challenge: c.challenge, redirectUri: c.redirectUri }).toString();
|
|
72
|
+
try {
|
|
73
|
+
try {
|
|
74
|
+
await browser(url.href);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new CliError('BROWSER_FAILED');
|
|
78
|
+
}
|
|
79
|
+
const { code } = await c.result;
|
|
80
|
+
const data = z.object({ token: z.string().min(1), expiresAt: z.iso.datetime(), user: userSchema }).parse(await api.request('/auth/exchange', { method: 'POST', anonymous: true, body: { code, verifier: c.verifier, redirectUri: c.redirectUri } }));
|
|
81
|
+
api.token = data.token;
|
|
82
|
+
try {
|
|
83
|
+
await store.set(api.origin, { token: data.token, expiresAt: data.expiresAt, userId: data.user.id });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
try {
|
|
87
|
+
await api.request('/auth/revoke', { method: 'POST', body: {} });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new CliError('REVOCATION_FAILED');
|
|
91
|
+
}
|
|
92
|
+
throw new CliError('SAVE_FAILED');
|
|
93
|
+
}
|
|
94
|
+
return { ...data.user, expiresAt: data.expiresAt };
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
c.cancel();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export async function status(api) { return meSchema.parse(await api.request('/me')); }
|
|
101
|
+
export async function logout(api, store = new CredentialStore()) {
|
|
102
|
+
const credential = await store.get(api.origin);
|
|
103
|
+
if (!credential)
|
|
104
|
+
return { revoked: true, localRemoved: true };
|
|
105
|
+
api.token = credential.token;
|
|
106
|
+
try {
|
|
107
|
+
await api.request('/auth/revoke', { method: 'POST', body: {} });
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
if (!(e instanceof CliError && ['CREDENTIAL_EXPIRED', 'INVALID_CREDENTIAL'].includes(e.code)))
|
|
111
|
+
throw new CliError('REVOCATION_FAILED');
|
|
112
|
+
}
|
|
113
|
+
await store.remove(api.origin);
|
|
114
|
+
return { revoked: true, localRemoved: true };
|
|
115
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { CliError, DEFAULT_SITE, normalizeSite, publicOutput, safeError, sha256, VERSION } from './core.js';
|
|
9
|
+
import { ApiClient, compatibleVersion } from './http.js';
|
|
10
|
+
import { login, logout, status } from './auth.js';
|
|
11
|
+
import { CredentialStore, configDir, privateDirectory, readInputJson, readPrivateJson, writePrivateJson } from './storage.js';
|
|
12
|
+
import { buildPlan, planSchema, revisePlan, textBlocks } from './plan.js';
|
|
13
|
+
import { executePlan, progressSchema } from './execute.js';
|
|
14
|
+
import { scan } from './scan.js';
|
|
15
|
+
import { installSkill, updatePackage, updateSkills } from './skills.js';
|
|
16
|
+
const help = {
|
|
17
|
+
command: 'yusijia', version: VERSION, platform: 'macOS arm64/x64, Node >=20.20.2',
|
|
18
|
+
commands: [
|
|
19
|
+
'auth login|status|logout [--site <origin>] [--json]',
|
|
20
|
+
'photos|albums|timeline|diary list [--limit 30] [--cursor <cursor>] [--json]',
|
|
21
|
+
'photos|albums|timeline|diary get --id <id> [--json] (photos only: --include-url)',
|
|
22
|
+
'photos scan --input <directory-or-zip> | --files <file> (repeatable) --out <scan.json>',
|
|
23
|
+
'photos import --input <scan.json> [--expectations <json>] --out <plan.json>',
|
|
24
|
+
'photos resume --plan <plan.json> --confirm-hash <hash>',
|
|
25
|
+
'photos cancel --id <batch-id> --out <plan.json>',
|
|
26
|
+
'albums create|update --input <payload.json> [--id <id>] --out <plan.json>',
|
|
27
|
+
'albums add|remove --id <id> --photo-id <id> (repeatable) --out <plan.json>',
|
|
28
|
+
'timeline create|update --input <payload.json> [--id <id>] --out <plan.json>',
|
|
29
|
+
'diary create --text <text> --out <plan.json> | --input <payload.json>',
|
|
30
|
+
'diary edit --id <id> --input <edits.json> --out <plan.json>',
|
|
31
|
+
'diary publish --id <id> --out <plan.json>',
|
|
32
|
+
'plan --input <draft.json> --out <plan.json> [--previous <old-plan.json>]',
|
|
33
|
+
'plan show --plan <plan.json>',
|
|
34
|
+
'execute --plan <plan.json> --confirm-hash <hash>',
|
|
35
|
+
'operations get --id <operation-uuid>',
|
|
36
|
+
'skills install [--dir <exact-skill-directory>] | skills update',
|
|
37
|
+
'update (public npm, global installation; beta stays on beta, stable uses latest)',
|
|
38
|
+
],
|
|
39
|
+
note: 'Content commands generate private local plans only. Execute requires confirmation of the current plan hash. --site selects a credential-isolated origin; default https://yusijia.me. See package README for typed actions and prior-result references.',
|
|
40
|
+
};
|
|
41
|
+
export function contentReadPath(resource, id, includeUrl = false) {
|
|
42
|
+
if (!['photos', 'albums', 'milestones', 'diaries'].includes(resource) || includeUrl && resource !== 'photos')
|
|
43
|
+
throw new CliError('INVALID_INPUT');
|
|
44
|
+
return `/${resource}/${encodeURIComponent(id)}${includeUrl ? '?includeUrl=true' : ''}`;
|
|
45
|
+
}
|
|
46
|
+
export async function runCli(argv, output = value => process.stdout.write(`${JSON.stringify(value, null, argv.includes('--json') ? undefined : 2)}\n`)) {
|
|
47
|
+
try {
|
|
48
|
+
let args;
|
|
49
|
+
try {
|
|
50
|
+
args = parseArgs({ args: argv, allowPositionals: true, strict: true, options: {
|
|
51
|
+
help: { type: 'boolean', short: 'h' }, version: { type: 'boolean' }, json: { type: 'boolean' },
|
|
52
|
+
site: { type: 'string' }, input: { type: 'string' }, out: { type: 'string' }, plan: { type: 'string' },
|
|
53
|
+
'confirm-hash': { type: 'string' }, previous: { type: 'string' }, id: { type: 'string' },
|
|
54
|
+
'expected-version': { type: 'string' }, limit: { type: 'string' }, cursor: { type: 'string' },
|
|
55
|
+
files: { type: 'string', multiple: true }, 'photo-id': { type: 'string', multiple: true },
|
|
56
|
+
text: { type: 'string' }, dir: { type: 'string' }, expectations: { type: 'string' },
|
|
57
|
+
'include-url': { type: 'boolean' },
|
|
58
|
+
} });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new CliError('INVALID_INPUT');
|
|
62
|
+
}
|
|
63
|
+
const [command, subcommand, ...extra] = args.positionals;
|
|
64
|
+
const options = args.values;
|
|
65
|
+
const str = (name) => typeof options[name] === 'string' ? options[name] : undefined;
|
|
66
|
+
const required = (name) => { const v = str(name); if (!v)
|
|
67
|
+
throw new CliError('INVALID_INPUT'); return v; };
|
|
68
|
+
const respond = (data, ok = true) => { output({ ok, data: publicOutput(data, command === 'photos' && subcommand === 'get' && options['include-url'] === true) }); return ok ? 0 : 1; };
|
|
69
|
+
if (options.version) {
|
|
70
|
+
output({ version: VERSION });
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
if (options.help || !command) {
|
|
74
|
+
output(help);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
if (extra.length || process.platform !== 'darwin' || !['arm64', 'x64'].includes(process.arch) || !compatibleVersion(process.versions.node, '>=20.20.2'))
|
|
78
|
+
throw new CliError('INVALID_INPUT');
|
|
79
|
+
if (options['include-url'] && !(command === 'photos' && subcommand === 'get'))
|
|
80
|
+
throw new CliError('INVALID_INPUT');
|
|
81
|
+
if (command === 'skills') {
|
|
82
|
+
if (subcommand === 'install')
|
|
83
|
+
return respond(await installSkill(str('dir')));
|
|
84
|
+
if (subcommand === 'update') {
|
|
85
|
+
const result = await updateSkills();
|
|
86
|
+
return respond(result, result.ok);
|
|
87
|
+
}
|
|
88
|
+
throw new CliError('INVALID_INPUT');
|
|
89
|
+
}
|
|
90
|
+
if (command === 'update' && !subcommand) {
|
|
91
|
+
const result = await updatePackage();
|
|
92
|
+
return respond(result, result.ok !== false);
|
|
93
|
+
}
|
|
94
|
+
if (command === 'photos' && subcommand === 'scan') {
|
|
95
|
+
const result = await scan({ input: str('input'), files: options.files });
|
|
96
|
+
await writePrivateJson(resolve(required('out')), result);
|
|
97
|
+
return respond(result);
|
|
98
|
+
}
|
|
99
|
+
if (command === 'plan' && subcommand === 'show') {
|
|
100
|
+
const plan = planSchema.parse(await readPrivateJson(resolve(required('plan'))));
|
|
101
|
+
const { planHash, ...core } = plan;
|
|
102
|
+
if (buildPlan(core).planHash !== planHash)
|
|
103
|
+
throw new CliError('CONFIRMATION_REQUIRED');
|
|
104
|
+
return respond(plan);
|
|
105
|
+
}
|
|
106
|
+
if (!['auth', 'photos', 'albums', 'timeline', 'diary', 'plan', 'execute', 'operations'].includes(command))
|
|
107
|
+
throw new CliError('INVALID_INPUT');
|
|
108
|
+
let loadedPlan;
|
|
109
|
+
if (command === 'execute' || command === 'photos' && subcommand === 'resume')
|
|
110
|
+
loadedPlan = planSchema.parse(await readPrivateJson(resolve(required('plan'))));
|
|
111
|
+
const origin = normalizeSite(str('site') ?? loadedPlan?.origin ?? DEFAULT_SITE);
|
|
112
|
+
const store = new CredentialStore();
|
|
113
|
+
const credential = await store.get(origin);
|
|
114
|
+
const api = new ApiClient(origin, credential?.token);
|
|
115
|
+
if (command === 'auth') {
|
|
116
|
+
if (subcommand === 'login')
|
|
117
|
+
return respond(await login(api, store));
|
|
118
|
+
if (subcommand === 'status')
|
|
119
|
+
return respond(await status(api));
|
|
120
|
+
if (subcommand === 'logout')
|
|
121
|
+
return respond(await logout(api, store));
|
|
122
|
+
throw new CliError('INVALID_INPUT');
|
|
123
|
+
}
|
|
124
|
+
if (command === 'operations' && subcommand === 'get')
|
|
125
|
+
return respond(await api.request(`/operations/${z.uuid().parse(required('id'))}`));
|
|
126
|
+
const resources = { photos: 'photos', albums: 'albums', timeline: 'milestones', diary: 'diaries' };
|
|
127
|
+
if (resources[command] && ['list', 'get'].includes(subcommand)) {
|
|
128
|
+
if (subcommand === 'get')
|
|
129
|
+
return respond(await api.request(contentReadPath(resources[command], required('id'), options['include-url'] === true)));
|
|
130
|
+
const limit = z.number().int().min(1).max(100).parse(Number(str('limit') ?? 30));
|
|
131
|
+
const cursor = str('cursor');
|
|
132
|
+
if (cursor && cursor.length > 512)
|
|
133
|
+
throw new CliError('INVALID_INPUT');
|
|
134
|
+
const query = new URLSearchParams({ limit: String(limit), ...(cursor ? { cursor } : {}) });
|
|
135
|
+
return respond(await api.request(`/${resources[command]}?${query}`));
|
|
136
|
+
}
|
|
137
|
+
const user = await status(api);
|
|
138
|
+
const identity = { origin, userId: user.id };
|
|
139
|
+
const stateDir = join(configDir(), 'operations');
|
|
140
|
+
await privateDirectory(stateDir);
|
|
141
|
+
const progressFile = join(stateDir, `${sha256(`${origin}:${user.id}`)}.json`);
|
|
142
|
+
let previousProgress;
|
|
143
|
+
try {
|
|
144
|
+
previousProgress = await readPrivateJson(progressFile);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
if (e.code !== 'ENOENT')
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
if (loadedPlan) {
|
|
151
|
+
const result = await executePlan(loadedPlan, required('confirm-hash'), identity, api, previousProgress, p => writePrivateJson(progressFile, p));
|
|
152
|
+
return respond({ items: result.items }, result.ok);
|
|
153
|
+
}
|
|
154
|
+
let plan;
|
|
155
|
+
if (command === 'plan' && !subcommand) {
|
|
156
|
+
const draft = z.object({ schemaVersion: z.literal(1).optional(), origin: z.string().optional(), userId: z.string().optional(), items: z.array(z.json()) }).strict().parse(await readInputJson(required('input')));
|
|
157
|
+
if ((draft.origin && normalizeSite(draft.origin) !== origin) || (draft.userId && draft.userId !== user.id))
|
|
158
|
+
throw new CliError('CONFIRMATION_REQUIRED');
|
|
159
|
+
const data = { ...draft, ...identity };
|
|
160
|
+
if (str('previous')) {
|
|
161
|
+
const previous = planSchema.parse(await readPrivateJson(required('previous')));
|
|
162
|
+
const sent = new Set(previousProgress ? Object.keys(progressSchema.parse(previousProgress).entries) : []);
|
|
163
|
+
plan = revisePlan(previous, data, sent);
|
|
164
|
+
}
|
|
165
|
+
else
|
|
166
|
+
plan = buildPlan(data);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const operationId = randomUUID();
|
|
170
|
+
let action;
|
|
171
|
+
if (command === 'photos' && subcommand === 'import') {
|
|
172
|
+
const scan = z.object({ manifest: z.json(), files: z.record(z.string(), z.string()) }).passthrough().parse(await readPrivateJson(required('input')));
|
|
173
|
+
const candidates = z.object({ candidates: z.array(z.object({ localCandidateId: z.string(), status: z.string() })) }).parse(scan.manifest).candidates;
|
|
174
|
+
const expectations = str('expectations') ? await readInputJson(required('expectations')) : Object.fromEntries(candidates.filter(c => c.status === 'SELECTED').map(c => [c.localCandidateId, { mode: 'create' }]));
|
|
175
|
+
action = { operationId, action: 'photos.import', payload: { manifest: scan.manifest }, files: scan.files, expectations };
|
|
176
|
+
}
|
|
177
|
+
else if (command === 'photos' && subcommand === 'cancel')
|
|
178
|
+
action = { operationId, action: 'photos.cancel', target: required('id'), payload: {} };
|
|
179
|
+
else {
|
|
180
|
+
const supported = command === 'albums' ? ['create', 'update', 'add', 'remove'] : command === 'timeline' ? ['create', 'update'] : command === 'diary' ? ['create', 'edit', 'publish'] : [];
|
|
181
|
+
if (!supported.includes(subcommand))
|
|
182
|
+
throw new CliError('INVALID_INPUT');
|
|
183
|
+
let payload;
|
|
184
|
+
if (command === 'albums' && ['add', 'remove'].includes(subcommand))
|
|
185
|
+
payload = { add: subcommand === 'add' ? options['photo-id'] ?? [] : [], remove: subcommand === 'remove' ? options['photo-id'] ?? [] : [] };
|
|
186
|
+
else if (command === 'diary' && subcommand === 'publish')
|
|
187
|
+
payload = { status: 'PUBLISHED' };
|
|
188
|
+
else if (command === 'diary' && subcommand === 'create' && str('text'))
|
|
189
|
+
payload = { blocks: textBlocks(required('text')), status: 'DRAFT' };
|
|
190
|
+
else
|
|
191
|
+
payload = await readInputJson(required('input'));
|
|
192
|
+
action = { operationId, action: `${command}.${command === 'albums' && ['add', 'remove'].includes(subcommand) ? 'photos' : subcommand}`, payload };
|
|
193
|
+
if (subcommand !== 'create') {
|
|
194
|
+
action.target = required('id');
|
|
195
|
+
action.expectedVersion = str('expected-version') ?? z.object({ version: z.string() }).parse(await api.request(`/${resources[command]}/${encodeURIComponent(required('id'))}`)).version;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
plan = buildPlan({ ...identity, items: [action] });
|
|
199
|
+
}
|
|
200
|
+
await writePrivateJson(resolve(required('out')), plan);
|
|
201
|
+
return respond({ ...plan, account: { id: user.id, email: user.email, name: user.name, role: user.role } });
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
const error = safeError(e instanceof z.ZodError || e instanceof SyntaxError ? new CliError('INVALID_INPUT') : e);
|
|
205
|
+
output({ ok: false, error });
|
|
206
|
+
return 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url))
|
|
210
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const VERSION: string;
|
|
2
|
+
export declare const LIMITS: {
|
|
3
|
+
maxRequestBytes: number;
|
|
4
|
+
maxCandidates: number;
|
|
5
|
+
maxActions: number;
|
|
6
|
+
maxFileBytes: number;
|
|
7
|
+
};
|
|
8
|
+
export declare const DEFAULT_SITE = "https://yusijia.me";
|
|
9
|
+
export declare class CliError extends Error {
|
|
10
|
+
code: string;
|
|
11
|
+
retryable: boolean;
|
|
12
|
+
status?: number | undefined;
|
|
13
|
+
constructor(code: string, retryable?: boolean, status?: number | undefined);
|
|
14
|
+
}
|
|
15
|
+
export declare function safeError(error: unknown): {
|
|
16
|
+
code: string;
|
|
17
|
+
message: string;
|
|
18
|
+
retryable: boolean;
|
|
19
|
+
};
|
|
20
|
+
export declare function normalizeSite(site?: string): string;
|
|
21
|
+
export declare function sha256(value: string | Buffer): string;
|
|
22
|
+
export declare function canonicalJson(value: unknown): string;
|
|
23
|
+
export declare function checkBytes(value: unknown, max?: number): string;
|
|
24
|
+
export declare function publicOutput(value: unknown, allowImageUrls?: boolean, parentKey?: string): unknown;
|