@ium/tfy-cli 0.1.0-preview.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,54 @@
1
+ # TFY CLI Developer Preview
2
+
3
+ This npm package installs the `tfy` command for TFY Developer Preview dogfood/testing.
4
+
5
+ Install preview builds explicitly:
6
+
7
+ ```sh
8
+ npm install -g @ium/tfy-cli@preview
9
+ ```
10
+
11
+ Name mapping:
12
+
13
+ | Layer | Name |
14
+ | --- | --- |
15
+ | npm package | `@ium/tfy-cli` |
16
+ | Installed command | `tfy` |
17
+ | Rust CLI crate | `tfy-cli` |
18
+
19
+ TFY uses two public install channels only:
20
+
21
+ - Stable channel: `@ium/tfy-cli` resolves to the most tested release through the npm `latest` dist-tag.
22
+ - Public-test channel: `@ium/tfy-cli@preview` resolves to the newest public testing build with current development work included.
23
+
24
+ Exact versions remain installable with standard npm syntax, for example `@ium/tfy-cli@0.1.1` or `@ium/tfy-cli@0.1.1-preview.0`; npm version installs use `@<version>`, not `/v<version>`.
25
+
26
+ Supported prebuilt npm/GitHub Release platforms:
27
+
28
+ | OS | Architecture | Rust target | npm prebuilt |
29
+ | --- | --- | --- | --- |
30
+ | macOS | Apple Silicon arm64 | `aarch64-apple-darwin` | yes |
31
+ | Linux | x64 | `x86_64-unknown-linux-gnu` | yes |
32
+ | Linux | arm64 | `aarch64-unknown-linux-gnu` | yes |
33
+ | Windows | x64 | `x86_64-pc-windows-msvc` | yes |
34
+
35
+ Intel Mac (`darwin:x64` / `x86_64-apple-darwin`) is not provided as a prebuilt npm/GitHub Release archive. Intel Mac users can still build from source with `git clone https://github.com/ium-team/tfy && cd tfy && cargo install --path crates/tfy-cli`, or run a locally built binary by setting `TFY_BINARY_PATH`.
36
+
37
+
38
+ The npm package name is `@ium/tfy-cli` and differs from the command name because the unscoped `tfy` npm package is already occupied and unscoped `tfy-cli` is blocked by npm similarity policy. The installed executable remains `tfy`.
39
+
40
+ This is not GA/production-ready. TFY does not claim private Codex hook interception, provider prompt proxying, editor auto-hooks, or universal terminal interception. Setup success is not token-savings success; route-bound raw/ledger/no-negative/positive-savings evidence is still required.
41
+
42
+ The installer downloads from the matching GitHub Release tag and verifies the `.sha256` file before installing. Preview versions keep the full preview tag, for example `v0.1.1-preview.0`, while release asset names use the base version, for example `tfy-0.1.1-linux-x86_64.tar.gz`.
43
+
44
+
45
+ ## Publishing note
46
+
47
+ This package intentionally defaults `publishConfig.tag` to `preview` so accidental `npm publish` does not promote a public-test build to stable. Maintainers should run the root helper before publishing:
48
+
49
+ ```sh
50
+ node scripts/npm-publish-plan.js --version 0.1.1-preview.0 --channel preview --source-ref develop
51
+ node scripts/npm-publish-plan.js --version 0.1.1 --channel stable --source-ref main
52
+ ```
53
+
54
+ The helper validates the channel/version/source metadata and prints the exact `npm publish` command; it does not publish.
package/bin/tfy.js ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+ const { currentPlatform } = require('../scripts/lib/platform');
8
+
9
+ const root = path.resolve(__dirname, '..');
10
+
11
+ function resolveLauncherBinary(options = {}) {
12
+ const env = options.env || process.env;
13
+ if (env.TFY_BINARY_PATH) return { binary: env.TFY_BINARY_PATH, target: null };
14
+ const target = currentPlatform({ platform: options.platform, arch: options.arch });
15
+ return {
16
+ binary: path.join(root, 'vendor', target.rustTarget, target.binName),
17
+ target
18
+ };
19
+ }
20
+
21
+ function main(argv = process.argv.slice(2), options = {}) {
22
+ const env = options.env || process.env;
23
+ let resolved;
24
+ try {
25
+ resolved = resolveLauncherBinary({ env, platform: options.platform, arch: options.arch });
26
+ } catch (error) {
27
+ process.stderr.write(`[tfy] ${error.message}\n`);
28
+ process.stderr.write('[tfy] set TFY_BINARY_PATH to a built tfy binary if you want to run from source on this platform.\n');
29
+ process.exit(127);
30
+ }
31
+
32
+ const { binary, target } = resolved;
33
+ if (!fs.existsSync(binary)) {
34
+ const platformLabel = target ? target.key : 'TFY_BINARY_PATH';
35
+ process.stderr.write(`[tfy] missing TFY binary for ${platformLabel}: ${binary}\n`);
36
+ process.stderr.write('[tfy] reinstall the package or set TFY_BINARY_PATH to a built tfy binary.\n');
37
+ process.exit(127);
38
+ }
39
+
40
+ const result = spawnSync(binary, argv, {
41
+ stdio: 'inherit',
42
+ shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(binary)
43
+ });
44
+ if (result.error) {
45
+ process.stderr.write(`[tfy] failed to execute ${binary}: ${result.error.message}\n`);
46
+ process.exit(127);
47
+ }
48
+ if (result.signal) {
49
+ process.kill(process.pid, result.signal);
50
+ } else {
51
+ process.exit(result.status === null ? 1 : result.status);
52
+ }
53
+ }
54
+
55
+ if (require.main === module) main();
56
+
57
+ module.exports = {
58
+ main,
59
+ resolveLauncherBinary
60
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@ium/tfy-cli",
3
+ "version": "0.1.0-preview.0",
4
+ "description": "TFY Developer Preview CLI installer. Installs the `tfy` command; not GA.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ium-team/tfy.git"
9
+ },
10
+ "homepage": "https://github.com/ium-team/tfy#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/ium-team/tfy/issues"
13
+ },
14
+ "keywords": [
15
+ "tfy",
16
+ "cli",
17
+ "ai-agent",
18
+ "token-saving",
19
+ "developer-preview"
20
+ ],
21
+ "bin": {
22
+ "tfy": "bin/tfy.js"
23
+ },
24
+ "scripts": {
25
+ "postinstall": "node scripts/install.js",
26
+ "test": "node test/platform.test.js && node test/checksum.test.js && node test/install.test.js && node test/launcher.test.js && node test/release.test.js"
27
+ },
28
+ "files": [
29
+ "bin/",
30
+ "scripts/",
31
+ "vendor/",
32
+ "README.md"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "tag": "preview"
37
+ },
38
+ "engines": {
39
+ "node": ">=18"
40
+ }
41
+ }
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const crypto = require('crypto');
5
+ const fs = require('fs');
6
+ const https = require('https');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { spawnSync } = require('child_process');
10
+ const { currentPlatform, archiveName, cleanVersion } = require('./lib/platform');
11
+ const { parseChecksum } = require('./lib/checksum');
12
+
13
+ const root = path.resolve(__dirname, '..');
14
+ const pkg = require(path.join(root, 'package.json'));
15
+ const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000;
16
+ const DEFAULT_REDIRECT_LIMIT = 5;
17
+
18
+ function log(message) {
19
+ process.stderr.write(`[tfy installer] ${message}\n`);
20
+ }
21
+
22
+ function fail(message) {
23
+ process.stderr.write(`[tfy installer] error: ${message}\n`);
24
+ process.exit(1);
25
+ }
26
+
27
+ function sha256(file) {
28
+ const hash = crypto.createHash('sha256');
29
+ hash.update(fs.readFileSync(file));
30
+ return hash.digest('hex');
31
+ }
32
+
33
+ function ensureExecutable(file) {
34
+ if (process.platform !== 'win32') fs.chmodSync(file, 0o755);
35
+ }
36
+
37
+ function defaultInstallPaths(platformTarget) {
38
+ const vendorDir = path.join(root, 'vendor', platformTarget.rustTarget);
39
+ return { vendorDir, installedBin: path.join(vendorDir, platformTarget.binName) };
40
+ }
41
+
42
+ function manualInstallDestination(source) {
43
+ const name = path.basename(source) || (process.platform === 'win32' ? 'tfy.exe' : 'tfy');
44
+ return path.join(root, 'vendor', 'manual', name);
45
+ }
46
+
47
+ function copyLocalBinary(source, destination = manualInstallDestination(source)) {
48
+ if (!fs.existsSync(source)) throw new Error(`TFY_BINARY_PATH does not exist: ${source}`);
49
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
50
+ fs.copyFileSync(source, destination);
51
+ ensureExecutable(destination);
52
+ log(`installed local TFY binary from ${source}`);
53
+ return destination;
54
+ }
55
+
56
+ function installLocalBinary(source, options = {}) {
57
+ const resolvedSource = path.resolve(source);
58
+ let destination = options.destinationBinary;
59
+ if (!destination) {
60
+ try {
61
+ const platformTarget = options.platformTarget || currentPlatform(options);
62
+ destination = defaultInstallPaths(platformTarget).installedBin;
63
+ } catch (error) {
64
+ destination = manualInstallDestination(resolvedSource);
65
+ log(`${error.message}; copied local binary to ${destination}. Set TFY_BINARY_PATH at runtime on unsupported platforms.`);
66
+ }
67
+ }
68
+ return copyLocalBinary(resolvedSource, destination);
69
+ }
70
+
71
+ function canonicalReleaseBase(version) {
72
+ const cleanVersion = String(version).replace(/^v/, '');
73
+ return `https://github.com/ium-team/tfy/releases/download/v${cleanVersion}`;
74
+ }
75
+
76
+ function defaultReleaseVersion(version) {
77
+ return cleanVersion(version);
78
+ }
79
+
80
+ function releaseUrls(version, platformTarget) {
81
+ const asset = archiveName(version, platformTarget);
82
+ const base = canonicalReleaseBase(version);
83
+ const archiveUrl = `${base}/${asset}`;
84
+ return { asset, archiveUrl, checksumUrl: `${archiveUrl}.sha256` };
85
+ }
86
+
87
+ function download(url, destination, options = {}) {
88
+ const timeoutMs = options.timeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
89
+ const redirectLimit = options.redirectLimit ?? DEFAULT_REDIRECT_LIMIT;
90
+ return new Promise((resolve, reject) => {
91
+ function fetch(currentUrl, remainingRedirects) {
92
+ const request = https.get(currentUrl, response => {
93
+ if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
94
+ response.resume();
95
+ if (remainingRedirects <= 0) return reject(new Error(`too many redirects downloading ${url}`));
96
+ if (!response.headers.location) return reject(new Error(`redirect missing Location for ${currentUrl}`));
97
+ const nextUrl = new URL(response.headers.location, currentUrl).toString();
98
+ return fetch(nextUrl, remainingRedirects - 1);
99
+ }
100
+ if (response.statusCode !== 200) {
101
+ response.resume();
102
+ return reject(new Error(`download failed ${response.statusCode}: ${currentUrl}`));
103
+ }
104
+ const out = fs.createWriteStream(destination, { flags: 'wx' });
105
+ response.pipe(out);
106
+ out.on('finish', () => out.close(resolve));
107
+ out.on('error', reject);
108
+ });
109
+ request.setTimeout(timeoutMs, () => request.destroy(new Error(`download timed out: ${currentUrl}`)));
110
+ request.on('error', reject);
111
+ }
112
+ fetch(url, redirectLimit);
113
+ });
114
+ }
115
+
116
+ function normalizeArchiveEntry(entry) {
117
+ return String(entry).replace(/\\/g, '/').replace(/^\.\//, '');
118
+ }
119
+
120
+ function validateArchiveEntries(entries, platformTarget) {
121
+ const normalized = entries.map(normalizeArchiveEntry).filter(Boolean);
122
+ if (normalized.length !== 1 || normalized[0] !== platformTarget.binName) {
123
+ throw new Error(`archive must contain only ${platformTarget.binName}; found: ${normalized.join(', ') || '(empty)'}`);
124
+ }
125
+ const entry = normalized[0];
126
+ if (path.posix.isAbsolute(entry) || entry.split('/').includes('..')) {
127
+ throw new Error(`unsafe archive entry: ${entry}`);
128
+ }
129
+ return entry;
130
+ }
131
+
132
+ function listArchiveEntries(archivePath) {
133
+ const listed = spawnSync('tar', ['-tzf', archivePath], { encoding: 'utf8' });
134
+ if (listed.status !== 0) {
135
+ throw new Error(`failed to inspect archive ${archivePath}: ${(listed.stderr || '').trim()}`);
136
+ }
137
+ return listed.stdout.split(/\r?\n/).filter(Boolean);
138
+ }
139
+
140
+ function installVerifiedArchive({ archivePath, checksumPath, asset, platformTarget, vendorDirectory, destinationBinary }) {
141
+ const expected = parseChecksum(fs.readFileSync(checksumPath, 'utf8'), asset);
142
+ const actual = sha256(archivePath);
143
+ if (actual !== expected) throw new Error(`checksum mismatch for ${asset}: expected ${expected}, got ${actual}`);
144
+
145
+ validateArchiveEntries(listArchiveEntries(archivePath), platformTarget);
146
+ const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tfy-extract-'));
147
+ const extractedPath = path.join(extractDir, platformTarget.binName);
148
+ const tar = spawnSync('tar', ['-xzf', archivePath, '-C', extractDir], { stdio: 'inherit' });
149
+ if (tar.status !== 0) throw new Error(`failed to extract ${asset} with tar`);
150
+ const realExtractRoot = fs.realpathSync(extractDir);
151
+ const realExtractedParent = fs.realpathSync(path.dirname(extractedPath));
152
+ if (realExtractedParent !== realExtractRoot) throw new Error(`archive extracted outside temp directory: ${platformTarget.binName}`);
153
+ const stat = fs.lstatSync(extractedPath);
154
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`archive entry is not a regular binary file: ${platformTarget.binName}`);
155
+
156
+ fs.mkdirSync(vendorDirectory, { recursive: true });
157
+ fs.copyFileSync(extractedPath, destinationBinary);
158
+ ensureExecutable(destinationBinary);
159
+ }
160
+
161
+ async function installFromRelease(options = {}) {
162
+ const platformTarget = options.platformTarget || currentPlatform(options);
163
+ const version = defaultReleaseVersion(options.version || process.env.TFY_RELEASE_VERSION || pkg.version);
164
+ const { asset, archiveUrl, checksumUrl } = releaseUrls(version, platformTarget);
165
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tfy-npm-'));
166
+ const archivePath = path.join(tmp, asset);
167
+ const checksumPath = `${archivePath}.sha256`;
168
+ const downloadFile = options.downloadFile || download;
169
+ const defaults = defaultInstallPaths(platformTarget);
170
+ const vendorDirectory = options.vendorDirectory || defaults.vendorDir;
171
+ const destinationBinary = options.destinationBinary || defaults.installedBin;
172
+ log(`downloading ${archiveUrl}`);
173
+ await downloadFile(archiveUrl, archivePath);
174
+ await downloadFile(checksumUrl, checksumPath);
175
+ installVerifiedArchive({ archivePath, checksumPath, asset, platformTarget, vendorDirectory, destinationBinary });
176
+ log(`installed ${destinationBinary}`);
177
+ }
178
+
179
+ async function main() {
180
+ if (process.env.TFY_SKIP_INSTALL === '1') {
181
+ log('TFY_SKIP_INSTALL=1; skipping binary install');
182
+ return;
183
+ }
184
+ if (process.env.TFY_BINARY_PATH) {
185
+ installLocalBinary(process.env.TFY_BINARY_PATH);
186
+ return;
187
+ }
188
+ await installFromRelease();
189
+ }
190
+
191
+ if (require.main === module) {
192
+ main().catch(error => fail(error.message));
193
+ }
194
+
195
+ module.exports = {
196
+ canonicalReleaseBase,
197
+ copyLocalBinary,
198
+ defaultInstallPaths,
199
+ defaultReleaseVersion,
200
+ download,
201
+ installFromRelease,
202
+ installLocalBinary,
203
+ manualInstallDestination,
204
+ installVerifiedArchive,
205
+ listArchiveEntries,
206
+ normalizeArchiveEntry,
207
+ releaseUrls,
208
+ sha256,
209
+ validateArchiveEntries
210
+ };
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ function parseChecksum(text, asset) {
4
+ const expectedName = String(asset);
5
+ for (const line of String(text).split(/\r?\n/)) {
6
+ const trimmed = line.trim();
7
+ if (!trimmed) continue;
8
+ const parts = trimmed.split(/\s+/);
9
+ const hash = parts[0];
10
+ if (!/^[a-fA-F0-9]{64}$/.test(hash || '')) continue;
11
+ if (parts.length === 1) return hash.toLowerCase();
12
+ const filename = parts.slice(1).join(' ').replace(/^\*/, '');
13
+ if (filename === expectedName) return hash.toLowerCase();
14
+ }
15
+ throw new Error(`No checksum entry found for ${asset}`);
16
+ }
17
+
18
+ module.exports = { parseChecksum };
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ const SUPPORTED = new Map([
4
+ ['darwin:arm64', { platform: 'darwin', arch: 'arm64', rustTarget: 'aarch64-apple-darwin', archivePlatform: 'darwin', archiveArch: 'arm64', binName: 'tfy' }],
5
+ ['linux:x64', { platform: 'linux', arch: 'x64', rustTarget: 'x86_64-unknown-linux-gnu', archivePlatform: 'linux', archiveArch: 'x86_64', binName: 'tfy' }],
6
+ ['linux:arm64', { platform: 'linux', arch: 'arm64', rustTarget: 'aarch64-unknown-linux-gnu', archivePlatform: 'linux', archiveArch: 'arm64', binName: 'tfy' }],
7
+ ['win32:x64', { platform: 'win32', arch: 'x64', rustTarget: 'x86_64-pc-windows-msvc', archivePlatform: 'windows', archiveArch: 'x86_64', binName: 'tfy.exe' }]
8
+ ]);
9
+
10
+ function cloneTarget(key, target) {
11
+ return { ...target, key };
12
+ }
13
+
14
+ function sourceBuildGuidance() {
15
+ return 'Intel Mac prebuilt npm installs are not currently provided. Build from source with `git clone https://github.com/ium-team/tfy && cd tfy && cargo install --path crates/tfy-cli`, or set TFY_BINARY_PATH to a built tfy binary.';
16
+ }
17
+
18
+ function supportedPlatformList() {
19
+ return Array.from(SUPPORTED.keys()).join(', ');
20
+ }
21
+
22
+ function unsupportedPlatformMessage(platform, arch) {
23
+ const key = `${platform}:${arch}`;
24
+ return `Unsupported TFY npm platform ${key}. Supported prebuilt platforms: ${supportedPlatformList()}. ${sourceBuildGuidance()}`;
25
+ }
26
+
27
+ function currentPlatform(options = {}) {
28
+ return resolvePlatform(options.platform || process.platform, options.arch || process.arch);
29
+ }
30
+
31
+ function resolvePlatform(platform, arch) {
32
+ const key = `${platform}:${arch}`;
33
+ const resolved = SUPPORTED.get(key);
34
+ if (!resolved) {
35
+ throw new Error(unsupportedPlatformMessage(platform, arch));
36
+ }
37
+ return cloneTarget(key, resolved);
38
+ }
39
+
40
+ function supportedTargets() {
41
+ return Array.from(SUPPORTED.entries()).map(([key, target]) => cloneTarget(key, target));
42
+ }
43
+
44
+ function resolveRustTarget(rustTarget) {
45
+ const resolved = supportedTargets().find(target => target.rustTarget === rustTarget);
46
+ if (!resolved) {
47
+ const supported = supportedTargets().map(target => target.rustTarget).join(', ');
48
+ throw new Error(`Unsupported TFY Rust target ${rustTarget}. Supported: ${supported}`);
49
+ }
50
+ return resolved;
51
+ }
52
+
53
+ function cleanVersion(version) {
54
+ return String(version).replace(/^v/, '');
55
+ }
56
+
57
+ function baseVersion(version) {
58
+ return cleanVersion(version).replace(/-preview\.\d+$/, '');
59
+ }
60
+
61
+ function assetBaseName(version, target) {
62
+ return `tfy-${baseVersion(version)}-${target.archivePlatform}-${target.archiveArch}`;
63
+ }
64
+
65
+ function archiveName(version, target) {
66
+ return `${assetBaseName(version, target)}.tar.gz`;
67
+ }
68
+
69
+ module.exports = {
70
+ SUPPORTED,
71
+ archiveName,
72
+ assetBaseName,
73
+ baseVersion,
74
+ cleanVersion,
75
+ currentPlatform,
76
+ sourceBuildGuidance,
77
+ resolvePlatform,
78
+ resolveRustTarget,
79
+ supportedPlatformList,
80
+ supportedTargets,
81
+ unsupportedPlatformMessage
82
+ };