@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 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
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ type Output = (value: unknown) => void;
3
+ export declare function contentReadPath(resource: string, id: string, includeUrl?: boolean): string;
4
+ export declare function runCli(argv: string[], output?: Output): Promise<number>;
5
+ export {};
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;