fangcun 0.0.0-stage → 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 +44 -2
- package/bin/fangcun.js +52 -0
- package/install.js +82 -0
- package/lib/download.js +201 -0
- package/lib/launch.js +71 -0
- package/lib/platforms.js +77 -0
- package/lib/resolve.js +104 -0
- package/package.json +36 -4
package/README.md
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
-
#
|
|
1
|
+
# fangcun
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
);
|
package/lib/download.js
ADDED
|
@@ -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 };
|
package/lib/platforms.js
ADDED
|
@@ -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,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fangcun",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
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
|
+
}
|
|
38
|
+
}
|