fangcun 0.0.0 → 0.1.0

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,45 @@
1
+ # fangcun
2
+
3
+ Command-line client for [Fangcun](https://github.com/tinyacre/Fangcun), a
4
+ self-hostable credit-card manager with end-to-end field encryption.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ npx fangcun@latest --help
10
+ # or
11
+ npm install -g fangcun
12
+ fangcun --help
13
+ ```
14
+
15
+ This package is a thin launcher (see [`bin/fangcun.js`](bin/fangcun.js)):
16
+ the real work is done by a native `fangcun` binary published per-platform
17
+ as an `optionalDependency` (`@tinyacre/fangcun-<os>-<arch>`). If npm skipped
18
+ that dependency (`--no-optional`, a mirrored registry, an unsupported
19
+ platform), the launcher downloads and checksum-verifies the matching release
20
+ asset from GitHub Releases on first use instead.
21
+
22
+ Override the binary directly with `FANGCUN_CLI_BINARY=/path/to/fangcun`.
23
+
24
+ ## Usage
25
+
26
+ ```sh
27
+ fangcun auth login --server https://vault.example # approve the code at <server>/device
28
+ fangcun cards list
29
+ fangcun cards get <id> --reveal
30
+ fangcun --json cards list | jq .
31
+ ```
32
+
33
+ See `docs/openapi.yaml` in the main repository for the full API surface the
34
+ `--json` output mirrors, and the `fangcun-cli` agent skill for a worked
35
+ example per command group.
36
+
37
+ ## Notes
38
+
39
+ - Sealed Fields (card number, CVV, expiry, notes) are end-to-end encrypted;
40
+ the CLI shows Masked Output by default and needs `--reveal` (and an
41
+ Unlocked client — `fangcun auth login --store-dek`, or `FANGCUN_DEK`)
42
+ to show clear values.
43
+ - `fangcun auth token` management (list/revoke) is available from the
44
+ CLI. Tokens are minted only in the Web App: `fangcun auth login` gets
45
+ one for this machine when you approve its code at `<server>/device`.
package/bin/fangcun.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('node:path');
5
+ const fs = require('node:fs');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ const { runCLI } = require('../lib/launch.js');
9
+ const { binaryFileName } = require('../lib/platforms.js');
10
+ const { downloadAndVerify } = require('../lib/download.js');
11
+ const pkg = require('../package.json');
12
+
13
+ // install.js caches a download at <package root>/.bin-cache/<version>/<binary>.
14
+ const cacheDir = path.join(
15
+ __dirname,
16
+ '..',
17
+ '.bin-cache',
18
+ pkg.version,
19
+ binaryFileName(process.platform),
20
+ );
21
+
22
+ // A dev/workspace checkout (version 0.0.0) has no matching GitHub release to
23
+ // fetch — skip the lazy retry entirely and go straight to the actionable
24
+ // failure if nothing else resolved.
25
+ const download =
26
+ pkg.version && pkg.version !== '0.0.0'
27
+ ? ({ destDir, destBinary }) =>
28
+ downloadAndVerify({
29
+ version: pkg.version,
30
+ platform: process.platform,
31
+ arch: process.arch,
32
+ destDir,
33
+ destBinary,
34
+ })
35
+ : undefined;
36
+
37
+ runCLI({
38
+ argv: process.argv.slice(2),
39
+ env: process.env,
40
+ platform: process.platform,
41
+ arch: process.arch,
42
+ requireResolve: require.resolve,
43
+ existsSync: fs.existsSync,
44
+ cacheDir,
45
+ download,
46
+ spawnSync,
47
+ stderr: process.stderr,
48
+ exit: (code) => process.exit(code),
49
+ }).catch((err) => {
50
+ process.stderr.write(`fangcun: unexpected launcher error: ${err && err.stack ? err.stack : err}\n`);
51
+ process.exit(1);
52
+ });
package/install.js ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // postinstall fallback download (design §7, resolution step 3).
5
+ //
6
+ // Runs only when the matching @tinyacre/fangcun-<os>-<arch> optionalDependency
7
+ // failed to install — e.g. `--no-optional`, a mirrored registry that dropped
8
+ // scoped packages, or a platform/arch this release doesn't publish yet. It
9
+ // downloads the matching release archive from GitHub Releases and verifies
10
+ // it against that release's checksums.txt (goreleaser's `checksum` block).
11
+ //
12
+ // MUST NOT fail `npm install`: every error path logs to stderr and exits 0
13
+ // (design §7: "install.js must be failure-tolerant"). If this step doesn't
14
+ // leave a usable cache, the launcher (bin/fangcun.js) retries the same
15
+ // download lazily on first invocation — see lib/resolve.js step 3 — so a
16
+ // postinstall hiccup never bricks the CLI, only delays first use.
17
+
18
+ const path = require('node:path');
19
+ const fs = require('node:fs');
20
+
21
+ const { platformPackageName, binaryFileName } = require('./lib/platforms.js');
22
+ const { downloadAndVerify } = require('./lib/download.js');
23
+ const pkg = require('./package.json');
24
+
25
+ function log(msg) {
26
+ process.stderr.write(`fangcun install: ${msg}\n`);
27
+ }
28
+
29
+ function haveOptionalDependency() {
30
+ try {
31
+ require.resolve(
32
+ `${platformPackageName(process.platform, process.arch)}/bin/${binaryFileName(process.platform)}`,
33
+ );
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ async function main() {
41
+ if (haveOptionalDependency()) {
42
+ return; // the launcher's step 2 will find it; nothing to do here
43
+ }
44
+
45
+ const version = pkg.version;
46
+ if (!version || version === '0.0.0') {
47
+ log('skipping fallback download: package version is 0.0.0 (dev/workspace checkout)');
48
+ return;
49
+ }
50
+
51
+ const destDir = path.join(__dirname, '.bin-cache', version);
52
+ const destBinary = path.join(destDir, binaryFileName(process.platform));
53
+ if (fs.existsSync(destBinary)) {
54
+ return;
55
+ }
56
+
57
+ try {
58
+ await downloadAndVerify({
59
+ version,
60
+ platform: process.platform,
61
+ arch: process.arch,
62
+ destDir,
63
+ destBinary,
64
+ });
65
+ log(`downloaded fangcun ${version} for ${process.platform}/${process.arch}`);
66
+ } catch (err) {
67
+ log(
68
+ `could not fetch a prebuilt binary for ${process.platform}/${process.arch} (${err.message}). ` +
69
+ 'This is not fatal: install the matching @tinyacre/fangcun-* package manually, set ' +
70
+ 'FANGCUN_CLI_BINARY to point at a fangcun binary, or just run `fangcun` — it ' +
71
+ 'retries this download on first use.',
72
+ );
73
+ }
74
+ }
75
+
76
+ main().then(
77
+ () => process.exit(0),
78
+ (err) => {
79
+ log(`unexpected error, continuing anyway: ${err && err.stack ? err.stack : err}`);
80
+ process.exit(0);
81
+ },
82
+ );
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+
3
+ // Shared download+verify+extract logic used by both install.js (the
4
+ // postinstall fallback) and the launcher's lazy retry (resolve.js step 3,
5
+ // design §7: "step 3 retries lazily"). Kept in one place so the two call
6
+ // sites can never drift on the checksum/extraction contract.
7
+
8
+ const path = require('node:path');
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const crypto = require('node:crypto');
12
+ const https = require('node:https');
13
+ const { execFileSync } = require('node:child_process');
14
+
15
+ const { archiveName, archiveNameForNpm } = require('./platforms.js');
16
+
17
+ const REPO = 'tinyacre/Fangcun';
18
+ const MAX_REDIRECTS = 5;
19
+
20
+ function releaseTag(version) {
21
+ return `v${version}`;
22
+ }
23
+
24
+ function assetURL(tag, name) {
25
+ return `https://github.com/${REPO}/releases/download/${tag}/${name}`;
26
+ }
27
+
28
+ // fetchBuffer follows redirects manually (GitHub release assets 302 to a
29
+ // CDN) and rejects non-2xx statuses, with no dependency on a redirect-
30
+ // following HTTP client library.
31
+ function fetchBuffer(url, redirectsLeft = MAX_REDIRECTS) {
32
+ return new Promise((resolve, reject) => {
33
+ https
34
+ .get(url, { headers: { 'User-Agent': 'fangcun-install.js' } }, (res) => {
35
+ const { statusCode, headers } = res;
36
+ if (statusCode >= 300 && statusCode < 400 && headers.location) {
37
+ res.resume();
38
+ if (redirectsLeft <= 0) {
39
+ reject(new Error(`too many redirects fetching ${url}`));
40
+ return;
41
+ }
42
+ resolve(fetchBuffer(headers.location, redirectsLeft - 1));
43
+ return;
44
+ }
45
+ if (statusCode !== 200) {
46
+ res.resume();
47
+ reject(new Error(`GET ${url} -> HTTP ${statusCode}`));
48
+ return;
49
+ }
50
+ const chunks = [];
51
+ res.on('data', (c) => chunks.push(c));
52
+ res.on('end', () => resolve(Buffer.concat(chunks)));
53
+ res.on('error', reject);
54
+ })
55
+ .on('error', reject);
56
+ });
57
+ }
58
+
59
+ function sha256Hex(buf) {
60
+ return crypto.createHash('sha256').update(buf).digest('hex');
61
+ }
62
+
63
+ // parseChecksums turns goreleaser's checksums.txt ("<hex> <filename>" per
64
+ // line) into a Map.
65
+ //
66
+ // L-9: checksums.txt itself is unsigned, fetched from the same GitHub
67
+ // Releases origin as the archive it covers, so this only ever proves the
68
+ // archive bytes match what the release lists — not who published the
69
+ // release. That's a deliberate scope limit, not an oversight: verification
70
+ // here is fail-closed (any mismatch throws) and correct for what it
71
+ // promises (transport/CDN integrity), it just doesn't extend trust beyond
72
+ // "GitHub Releases for this repo". Signing checksums.txt with an Ed25519
73
+ // release key would close that gap (see review L-9).
74
+ function parseChecksums(text) {
75
+ const map = new Map();
76
+ for (const line of text.split('\n')) {
77
+ const trimmed = line.trim();
78
+ if (!trimmed) continue;
79
+ const [hex, ...rest] = trimmed.split(/\s+/);
80
+ const name = rest.join(' ');
81
+ if (hex && name) map.set(name, hex.toLowerCase());
82
+ }
83
+ return map;
84
+ }
85
+
86
+ // writeArchiveToTempFile stages an already-downloaded (and, by the time
87
+ // this is called, checksum-verified) archive buffer under a private,
88
+ // randomly named temp directory (`fs.mkdtempSync`, always created mode
89
+ // 0700 regardless of umask) rather than a predictable path directly in the
90
+ // shared system tmpdir.
91
+ //
92
+ // M-5: a predictable path (the old code used
93
+ // `fangcun-${pid}-${Date.now()}-${name}` in os.tmpdir() directly) is
94
+ // guessable ahead of time on a multi-user or CI host; a symlink pre-planted
95
+ // at that exact name would redirect writeFileSync's write to an arbitrary
96
+ // file the attacker chose. mkdtempSync's directory name is unpredictable
97
+ // and only this process can write inside it.
98
+ //
99
+ // Caller must remove the returned tmpRoot when done.
100
+ function writeArchiveToTempFile(buffer, name) {
101
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fangcun-'));
102
+ const archivePath = path.join(tmpRoot, name);
103
+ fs.writeFileSync(archivePath, buffer);
104
+ return { tmpRoot, archivePath };
105
+ }
106
+
107
+ // installArchive extracts `archivePath` into destDir atomically: extraction
108
+ // happens in a private per-process staging directory
109
+ // (`${destDir}.tmp-<pid>`), the binary is verified and chmod'd *there*, and
110
+ // only then published into destDir with a single renameSync.
111
+ //
112
+ // M-4: the old code ran `tar -xf ... -C destDir` directly against the final
113
+ // destination and chmod'd after extraction, while resolve.js's cache check
114
+ // is a bare `existsSync(destBinary)`. Two concurrent `fangcun` invocations
115
+ // (or one interrupted mid-extract) could make a second invocation see the
116
+ // binary exist before extraction finished, or before the chmod ran, and
117
+ // spawn a truncated file or hit EACCES. Because nothing but the final
118
+ // rename ever touches destDir itself, an interrupted or concurrent run can
119
+ // only ever leave behind an abandoned staging directory — destDir is either
120
+ // absent or a fully-formed, already-chmod'd install.
121
+ function installArchive({ archivePath, destDir, destBinary, platform }) {
122
+ fs.mkdirSync(path.dirname(destDir), { recursive: true });
123
+
124
+ const stagingDir = `${destDir}.tmp-${process.pid}`;
125
+ fs.rmSync(stagingDir, { recursive: true, force: true });
126
+ fs.mkdirSync(stagingDir, { recursive: true });
127
+
128
+ try {
129
+ // Both tar.gz (darwin/linux) and zip (windows) extract via `tar`:
130
+ // Windows has shipped bsdtar since build 17063 (Win10 1803+), and
131
+ // libarchive auto-detects zip. This avoids an extra npm dependency
132
+ // just for archive extraction.
133
+ execFileSync('tar', ['-xf', archivePath, '-C', stagingDir], { stdio: 'pipe' });
134
+
135
+ const stagedBinary = path.join(stagingDir, path.basename(destBinary));
136
+ if (!fs.existsSync(stagedBinary)) {
137
+ throw new Error(`archive did not contain ${path.basename(destBinary)}`);
138
+ }
139
+ if (platform !== 'win32') {
140
+ fs.chmodSync(stagedBinary, 0o755);
141
+ }
142
+
143
+ try {
144
+ fs.renameSync(stagingDir, destDir);
145
+ } catch (err) {
146
+ // A concurrent invocation may have already published destDir first
147
+ // (same version + platform, two processes racing the same install) —
148
+ // that is success for this call too, not a failure.
149
+ if (!fs.existsSync(destBinary)) {
150
+ throw err;
151
+ }
152
+ }
153
+ } finally {
154
+ fs.rmSync(stagingDir, { recursive: true, force: true });
155
+ }
156
+
157
+ if (!fs.existsSync(destBinary)) {
158
+ throw new Error(`archive did not contain ${path.basename(destBinary)}`);
159
+ }
160
+ }
161
+
162
+ // downloadAndVerify fetches the release archive for `version`/platform/arch,
163
+ // verifies it against that release's checksums.txt, extracts it into
164
+ // destDir, and leaves the binary at destBinary. Throws on any failure —
165
+ // callers decide whether that's fatal; it never is for install.js or the
166
+ // launcher (design §7: "install.js must be failure-tolerant").
167
+ async function downloadAndVerify({ version, platform, arch, destDir, destBinary }) {
168
+ const tag = releaseTag(version);
169
+ const name = archiveNameForNpm(platform, arch);
170
+
171
+ const checksums = parseChecksums(
172
+ (await fetchBuffer(assetURL(tag, 'checksums.txt'))).toString('utf8'),
173
+ );
174
+ const want = checksums.get(name);
175
+ if (!want) {
176
+ throw new Error(`${name} not listed in checksums.txt for ${tag}`);
177
+ }
178
+
179
+ const archive = await fetchBuffer(assetURL(tag, name));
180
+ const got = sha256Hex(archive);
181
+ if (got !== want) {
182
+ throw new Error(`checksum mismatch for ${name}: want ${want}, got ${got}`);
183
+ }
184
+
185
+ const { tmpRoot, archivePath } = writeArchiveToTempFile(archive, name);
186
+ try {
187
+ installArchive({ archivePath, destDir, destBinary, platform });
188
+ } finally {
189
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
190
+ }
191
+ }
192
+
193
+ module.exports = {
194
+ downloadAndVerify,
195
+ archiveName,
196
+ assetURL,
197
+ parseChecksums,
198
+ sha256Hex,
199
+ installArchive,
200
+ writeArchiveToTempFile,
201
+ };
package/lib/launch.js ADDED
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const os = require('node:os');
4
+
5
+ const { resolveBinary } = require('./resolve.js');
6
+
7
+ // runCLI wires resolution + spawn + exit into one testable async function.
8
+ // Every effectful dependency is injected: bin/fangcun.js supplies the
9
+ // real ones, tests supply fakes.
10
+ //
11
+ // Contract asserted by tests: the child process always inherits stdio
12
+ // (interactive prompts, TTY detection and streaming output must work
13
+ // exactly as if `fangcun` were invoked directly) and windowsHide is set;
14
+ // the launcher's own exit code always mirrors the child's.
15
+ async function runCLI({
16
+ argv,
17
+ env,
18
+ platform,
19
+ arch,
20
+ requireResolve,
21
+ existsSync,
22
+ cacheDir,
23
+ download,
24
+ spawnSync,
25
+ stderr,
26
+ exit,
27
+ }) {
28
+ const result = await resolveBinary({
29
+ env,
30
+ platform,
31
+ arch,
32
+ requireResolve,
33
+ existsSync,
34
+ cacheDir,
35
+ download,
36
+ });
37
+
38
+ if (!result.binary) {
39
+ stderr.write(result.error + '\n');
40
+ exit(1);
41
+ return;
42
+ }
43
+
44
+ const child = spawnSync(result.binary, argv, {
45
+ stdio: 'inherit',
46
+ windowsHide: true,
47
+ });
48
+
49
+ if (child.error) {
50
+ stderr.write(`fangcun: failed to run ${result.binary}: ${child.error.message}\n`);
51
+ exit(1);
52
+ return;
53
+ }
54
+
55
+ if (child.signal) {
56
+ // Killed by a signal (e.g. SIGINT/SIGTERM forwarded from the parent
57
+ // terminal) rather than exiting normally — there is no numeric status.
58
+ // Mirror the standard POSIX/shell convention (128 + signal number) so
59
+ // scripts piping this exit code can tell a signal kill apart from an
60
+ // ordinary CLI error exit (L-4); fall back to 1 for a signal name Node
61
+ // doesn't have a number for on this platform.
62
+ stderr.write(`fangcun: terminated by signal ${child.signal}\n`);
63
+ const signum = os.constants.signals[child.signal];
64
+ exit(typeof signum === 'number' ? 128 + signum : 1);
65
+ return;
66
+ }
67
+
68
+ exit(child.status === null ? 1 : child.status);
69
+ }
70
+
71
+ module.exports = { runCLI };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ // Single source of truth for the npm platform/arch <-> Go GOOS/GOARCH <->
4
+ // release-archive-name mapping. Every consumer — the launcher's binary
5
+ // resolution (bin/fangcun.js, resolve.js), the postinstall/lazy download
6
+ // (install.js, download.js), and the release packaging script
7
+ // (npm/scripts/build-packages.mjs) — must go through this module so the
8
+ // mapping can never drift between copies again.
9
+ //
10
+ // H-2: download.js used to build archive names from Node's own
11
+ // process.platform/process.arch strings ("linux", "x64", ...) instead of
12
+ // GOOS/GOARCH ("linux", "amd64", ...), which broke the fallback download on
13
+ // 4 of the 6 published platforms (only darwin/arm64 and linux/arm64
14
+ // happened to coincide) while build-packages.mjs's independent copy of the
15
+ // same function stayed correct — nothing caught the two drifting apart.
16
+
17
+ // The six published targets: Node's process.platform/process.arch
18
+ // vocabulary paired with the GOOS/GOARCH pair goreleaser used to build and
19
+ // name the archive. build-packages.mjs iterates this same list to stage the
20
+ // six platform packages.
21
+ const PLATFORMS = [
22
+ { npmOs: 'darwin', npmArch: 'arm64', goos: 'darwin', goarch: 'arm64' },
23
+ { npmOs: 'darwin', npmArch: 'x64', goos: 'darwin', goarch: 'amd64' },
24
+ { npmOs: 'linux', npmArch: 'x64', goos: 'linux', goarch: 'amd64' },
25
+ { npmOs: 'linux', npmArch: 'arm64', goos: 'linux', goarch: 'arm64' },
26
+ { npmOs: 'win32', npmArch: 'x64', goos: 'windows', goarch: 'amd64' },
27
+ { npmOs: 'win32', npmArch: 'arm64', goos: 'windows', goarch: 'arm64' },
28
+ ];
29
+
30
+ function findPlatform(npmOs, npmArch) {
31
+ const found = PLATFORMS.find((p) => p.npmOs === npmOs && p.npmArch === npmArch);
32
+ if (!found) {
33
+ throw new Error(`unsupported platform/arch: ${npmOs}/${npmArch}`);
34
+ }
35
+ return found;
36
+ }
37
+
38
+ function archiveExt(goos) {
39
+ return goos === 'windows' ? 'zip' : 'tar.gz';
40
+ }
41
+
42
+ // Matches .goreleaser.yaml's archives.name_template ("fangcun_{{
43
+ // .Os }}_{{ .Arch }}") exactly — always GOOS/GOARCH, never Node's
44
+ // platform/arch strings. Pinned against the yaml itself by
45
+ // test/platforms.test.mjs.
46
+ function archiveName({ goos, goarch }) {
47
+ return `fangcun_${goos}_${goarch}.${archiveExt(goos)}`;
48
+ }
49
+
50
+ // archiveNameForNpm resolves a Node platform/arch pair (process.platform /
51
+ // process.arch) to its release archive name via the GOOS/GOARCH mapping
52
+ // above, so callers never interpolate Node's vocabulary into the archive
53
+ // name directly.
54
+ function archiveNameForNpm(npmOs, npmArch) {
55
+ const { goos, goarch } = findPlatform(npmOs, npmArch);
56
+ return archiveName({ goos, goarch });
57
+ }
58
+
59
+ // binaryFileName is the plain-file name the platform package / release
60
+ // archive ships the binary under.
61
+ function binaryFileName(npmOs) {
62
+ return npmOs === 'win32' ? 'fangcun.exe' : 'fangcun';
63
+ }
64
+
65
+ function platformPackageName(npmOs, npmArch) {
66
+ return `@tinyacre/fangcun-${npmOs}-${npmArch}`;
67
+ }
68
+
69
+ module.exports = {
70
+ PLATFORMS,
71
+ findPlatform,
72
+ archiveExt,
73
+ archiveName,
74
+ archiveNameForNpm,
75
+ binaryFileName,
76
+ platformPackageName,
77
+ };
package/lib/resolve.js ADDED
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+
5
+ const { platformPackageName, binaryFileName } = require('./platforms.js');
6
+
7
+ // Pure(-ish) resolution logic for the fangcun launcher (design doc §7,
8
+ // npm distribution). Every external effect is injected so it can be unit
9
+ // tested without a real npm install or network access.
10
+ //
11
+ // platformPackageName/binaryFileName are re-exported from platforms.js (the
12
+ // single npm platform/arch <-> GOOS/GOARCH <-> archive-name mapping, H-2) so
13
+ // existing callers/tests of this module keep working unchanged.
14
+
15
+ function missingBinaryMessage({ pkgName, cachedPath }) {
16
+ return [
17
+ 'fangcun: no CLI binary found for this platform.',
18
+ ` Expected optional dependency "${pkgName}" is not installed, and no`,
19
+ ` cached download exists at ${cachedPath}.`,
20
+ ' Fix one of:',
21
+ ' - reinstall without --no-optional / --omit=optional',
22
+ ` - install ${pkgName} directly`,
23
+ ' - set FANGCUN_CLI_BINARY to the path of a fangcun binary',
24
+ ].join('\n');
25
+ }
26
+
27
+ // resolveBinary implements the four-step resolution order from design §7:
28
+ // 1. FANGCUN_CLI_BINARY env override
29
+ // 2. the platform-specific optionalDependency package
30
+ // 3. install.js's downloaded cache under <package>/.bin-cache/<version>/,
31
+ // downloading lazily (via the injected `download`) if it isn't there
32
+ // yet — covers a postinstall that failed or was skipped
33
+ // (--ignore-scripts) without bricking the CLI itself.
34
+ // 4. an actionable failure
35
+ //
36
+ // `download` is optional: pass undefined to disable the lazy retry (e.g.
37
+ // a dev/workspace checkout with no release to fetch).
38
+ async function resolveBinary({
39
+ env = process.env,
40
+ platform = process.platform,
41
+ arch = process.arch,
42
+ requireResolve,
43
+ existsSync,
44
+ cacheDir,
45
+ download,
46
+ } = {}) {
47
+ if (typeof requireResolve !== 'function') {
48
+ throw new TypeError('resolveBinary: requireResolve is required');
49
+ }
50
+ if (typeof existsSync !== 'function') {
51
+ throw new TypeError('resolveBinary: existsSync is required');
52
+ }
53
+ if (typeof cacheDir !== 'string') {
54
+ throw new TypeError('resolveBinary: cacheDir is required');
55
+ }
56
+
57
+ // 1. explicit override — always wins, even if the path doesn't exist yet;
58
+ // the caller asked for it explicitly and gets the real spawn error
59
+ // instead of a substituted one.
60
+ if (env.FANGCUN_CLI_BINARY) {
61
+ return { binary: env.FANGCUN_CLI_BINARY, source: 'env' };
62
+ }
63
+
64
+ // 2. platform-specific optionalDependency
65
+ const pkgName = platformPackageName(platform, arch);
66
+ const fileName = binaryFileName(platform);
67
+ try {
68
+ const resolved = requireResolve(`${pkgName}/bin/${fileName}`);
69
+ return { binary: resolved, source: 'optionalDependency' };
70
+ } catch {
71
+ // Not installed — e.g. --no-optional, a mirrored registry that dropped
72
+ // scoped packages, or a platform/arch this release doesn't publish.
73
+ }
74
+
75
+ // 3. install.js's fallback download cache, retried lazily on demand
76
+ if (existsSync(cacheDir)) {
77
+ return { binary: cacheDir, source: 'cache' };
78
+ }
79
+ if (typeof download === 'function') {
80
+ try {
81
+ await download({ destDir: path.dirname(cacheDir), destBinary: cacheDir });
82
+ } catch {
83
+ // downloader already knows how to report its own failure upstream;
84
+ // fall through to the actionable step-4 message below.
85
+ }
86
+ if (existsSync(cacheDir)) {
87
+ return { binary: cacheDir, source: 'cache-downloaded' };
88
+ }
89
+ }
90
+
91
+ // 4. nothing found
92
+ return {
93
+ binary: null,
94
+ source: null,
95
+ error: missingBinaryMessage({ pkgName, cachedPath: cacheDir }),
96
+ };
97
+ }
98
+
99
+ module.exports = {
100
+ resolveBinary,
101
+ platformPackageName,
102
+ binaryFileName,
103
+ missingBinaryMessage,
104
+ };
package/package.json CHANGED
@@ -1,7 +1,38 @@
1
1
  {
2
2
  "name": "fangcun",
3
- "version": "0.0.0",
4
- "description": "Fangcun CLI (coming soon)",
5
- "license": "MIT",
6
- "repository": "github:tinyacre/Fangcun"
3
+ "version": "0.1.0",
4
+ "description": "Fangcun CLI — manage a self-hosted credit-card vault (cards, billing cycles, annual fees, benefits, reminders, the community catalog) from the shell.",
5
+ "bin": {
6
+ "fangcun": "bin/fangcun.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "lib",
11
+ "install.js",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "postinstall": "node install.js"
16
+ },
17
+ "optionalDependencies": {
18
+ "@tinyacre/fangcun-darwin-arm64": "0.1.0",
19
+ "@tinyacre/fangcun-darwin-x64": "0.1.0",
20
+ "@tinyacre/fangcun-linux-x64": "0.1.0",
21
+ "@tinyacre/fangcun-linux-arm64": "0.1.0",
22
+ "@tinyacre/fangcun-win32-x64": "0.1.0",
23
+ "@tinyacre/fangcun-win32-arm64": "0.1.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/tinyacre/Fangcun.git",
31
+ "directory": "npm/cli"
32
+ },
33
+ "homepage": "https://github.com/tinyacre/Fangcun",
34
+ "bugs": "https://github.com/tinyacre/Fangcun/issues",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
7
38
  }