├── typedoc.json ├── .gitignore ├── tsconfig.json ├── .github ├── dependabot.yml └── workflows │ ├── semantic-pull-request.yml │ ├── stale.yml │ └── js-test-and-release.yml ├── src ├── lib │ ├── link.ts │ ├── npm │ │ ├── bin │ │ │ ├── update.ts │ │ │ └── install.ts │ │ └── index.ts │ ├── use │ │ ├── ipfs-init.ts │ │ ├── symlink.ts │ │ ├── configure-node.ts │ │ ├── select-version.ts │ │ ├── write-lib-bin.ts │ │ ├── npm-install.ts │ │ └── index.ts │ ├── info.ts │ └── list.ts ├── commands │ ├── version.ts │ ├── info.ts │ ├── index.ts │ ├── help.ts │ ├── link.ts │ ├── use.ts │ └── list.ts ├── bin │ └── ipfs.tpl.ts ├── default-paths.ts ├── implementations.ts ├── bin.ts └── index.ts ├── LICENSE ├── package.json ├── test └── info.spec.ts └── README.md /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": [ 3 | "./src/index.ts" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | .docs 5 | .coverage 6 | node_modules 7 | package-lock.json 8 | yarn.lock 9 | .vscode 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "aegir/src/config/tsconfig.aegir.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": [ 7 | "src", 8 | "test" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | commit-message: 10 | prefix: "deps" 11 | prefix-development: "deps(dev)" 12 | -------------------------------------------------------------------------------- /.github/workflows/semantic-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Semantic PR 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | uses: pl-strflt/.github/.github/workflows/reusable-semantic-pull-request.yml@v0.3 13 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close and mark stale issue 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | permissions: 8 | issues: write 9 | pull-requests: write 10 | 11 | jobs: 12 | stale: 13 | uses: pl-strflt/.github/.github/workflows/reusable-stale-issue.yml@v0.3 14 | -------------------------------------------------------------------------------- /src/lib/link.ts: -------------------------------------------------------------------------------- 1 | import Fs from 'node:fs/promises' 2 | import symlink from './use/symlink.js' 3 | import type { Context } from '../bin.js' 4 | 5 | export default async (ctx: Context, currentBinLinkPath: string, binPath: string): Promise => { 6 | const { spinner } = ctx 7 | const libBinPath = await Fs.readlink(currentBinLinkPath) 8 | await symlink({ spinner }, libBinPath, binPath) 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/npm/bin/update.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-console */ 3 | 4 | import { promisify } from 'util' 5 | // @ts-expect-error no types 6 | import Npm from 'npm' 7 | 8 | process.on('uncaughtException', err => { console.error(err) }) 9 | process.on('unhandledRejection', err => { console.error(err); process.exit(1) }) 10 | 11 | const npm = await promisify(Npm.load)() 12 | 13 | await promisify(npm.commands.update)() 14 | -------------------------------------------------------------------------------- /src/commands/version.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import Path from 'node:path' 3 | import Url from 'node:url' 4 | import { readPackageUp } from 'read-package-up' 5 | 6 | export default { 7 | aliases: [], 8 | help: '', 9 | options: {}, 10 | run: async () => { 11 | const result = await readPackageUp({ cwd: Path.dirname(Url.fileURLToPath(import.meta.url)) }) 12 | console.info(result?.packageJson.version) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/use/ipfs-init.ts: -------------------------------------------------------------------------------- 1 | import { execa } from 'execa' 2 | import type { Context } from '../../bin.js' 3 | 4 | export default async function ipfsInit (ctx: Context, binPath: string, ipfsPath: string): Promise { 5 | const { spinner } = ctx 6 | 7 | spinner.start(`initializing IPFS at ${ipfsPath}`) 8 | try { 9 | await execa(binPath, ['init']) 10 | } catch (err) { 11 | spinner.fail('failed to init IPFS') 12 | throw err 13 | } 14 | spinner.succeed(`initialized IPFS at ${ipfsPath}`) 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/npm/bin/install.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-console */ 3 | 4 | import { promisify } from 'util' 5 | // @ts-expect-error no types 6 | import Npm from 'npm' 7 | 8 | process.on('uncaughtException', err => { console.error(err) }) 9 | process.on('unhandledRejection', err => { console.error(err); process.exit(1) }) 10 | 11 | const npm = await promisify(Npm.load)() 12 | 13 | if (npm == null) { 14 | throw new Error('Could not load npm') 15 | } 16 | 17 | await promisify(npm.commands.install)(process.cwd(), [process.argv.slice(2)[0]]) 18 | -------------------------------------------------------------------------------- /src/bin/ipfs.tpl.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | 4 | const ChildProcess = require('child_process') 5 | const Fs = require('fs') 6 | const Os = require('os') 7 | const Path = require('path') 8 | 9 | const env = Object.assign({}, process.env) 10 | 11 | if (env.IPFS_PATH == null) { 12 | const ipfsPath = Path.join(Os.homedir(), '{{IPFS_PATH}}') 13 | Fs.mkdirSync(ipfsPath, { recursive: true }) 14 | env.IPFS_PATH = ipfsPath 15 | } 16 | 17 | ChildProcess.spawn('{{IPFS_BIN_PATH}}', process.argv.slice(2), { env, stdio: 'inherit' }) 18 | -------------------------------------------------------------------------------- /src/default-paths.ts: -------------------------------------------------------------------------------- 1 | import Os from 'node:os' 2 | import Path from 'node:path' 3 | import Url from 'node:url' 4 | import { readPackageUp } from 'read-package-up' 5 | 6 | const result = await readPackageUp({ cwd: Path.dirname(Url.fileURLToPath(import.meta.url)) }) 7 | 8 | if (result == null) { 9 | throw new Error('Could not read package.json') 10 | } 11 | 12 | export const homePath = Path.join(Os.homedir(), `.${result.packageJson.name}`) 13 | export const installPath = Path.join(homePath, 'dists') 14 | 15 | // TODO: windows? 16 | // TODO: what is the bin path on windows? 17 | export const binPath = '/usr/local/bin/ipfs' 18 | 19 | export const currentBinLinkPath = Path.join(installPath, 'current') 20 | -------------------------------------------------------------------------------- /src/lib/use/symlink.ts: -------------------------------------------------------------------------------- 1 | import Fs from 'node:fs/promises' 2 | import type { Context } from '../../bin.js' 3 | 4 | export default async function symlink (ctx: Context, from: string, to: string): Promise { 5 | const { spinner } = ctx 6 | 7 | spinner.start(`symlinking ${from} -> ${to}`) 8 | try { 9 | await Fs.unlink(to) 10 | } catch (err: any) { 11 | // Ignore if not exists... 12 | if (err.code !== 'ENOENT') { 13 | spinner.fail(`failed to remove existing file ${to}`) 14 | throw err 15 | } 16 | } 17 | 18 | try { 19 | await Fs.symlink(from, to) 20 | } catch (err) { 21 | spinner.fail(`failed to symlink ${from} -> ${to}`) 22 | throw err 23 | } 24 | spinner.succeed(`symlinked ${from} -> ${to}`) 25 | } 26 | -------------------------------------------------------------------------------- /src/implementations.ts: -------------------------------------------------------------------------------- 1 | import Path from 'node:path' 2 | 3 | export interface Implementation { 4 | moduleName: string 5 | binPath: string 6 | configure: boolean 7 | update: boolean 8 | } 9 | 10 | export const implementations: Record = { 11 | js: { 12 | moduleName: 'ipfs', 13 | binPath: Path.join('node_modules', '.bin', 'jsipfs'), 14 | configure: true, 15 | update: true 16 | }, 17 | go: { 18 | moduleName: 'go-ipfs', 19 | binPath: Path.join('node_modules', 'go-ipfs', 'go-ipfs', 'ipfs'), 20 | configure: false, 21 | update: false 22 | }, 23 | kubo: { 24 | moduleName: 'kubo', 25 | binPath: Path.join('node_modules', 'kubo', 'bin', 'ipfs'), 26 | configure: false, 27 | update: false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/use/configure-node.ts: -------------------------------------------------------------------------------- 1 | import { execa } from 'execa' 2 | import type { Context } from '../../bin.js' 3 | 4 | export default async function configureNode (ctx: Context, binPath: string): Promise { 5 | const { spinner } = ctx 6 | 7 | spinner.start('configuring IPFS') 8 | try { 9 | await execa(binPath, ['config', 'Addresses.API', '/ip4/127.0.0.1/tcp/5001']) 10 | await execa(binPath, ['config', 'Addresses.Gateway', '/ip4/127.0.0.1/tcp/8080']) 11 | await execa(binPath, ['config', 'Addresses.Swarm', JSON.stringify([ 12 | '/ip4/0.0.0.0/tcp/4001', 13 | '/ip6/::/tcp/4001', 14 | '/ip4/127.0.0.1/tcp/4003/ws' 15 | ]), '--json']) 16 | } catch (err) { 17 | spinner.fail() 18 | throw err 19 | } 20 | spinner.succeed('configured IPFS') 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/js-test-and-release.yml: -------------------------------------------------------------------------------- 1 | name: test & maybe release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: write 12 | id-token: write 13 | packages: write 14 | pull-requests: write 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | js-test-and-release: 22 | uses: pl-strflt/uci/.github/workflows/js-test-and-release.yml@v0.0 23 | secrets: 24 | DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} 25 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | UCI_GITHUB_TOKEN: ${{ secrets.UCI_GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /src/lib/use/select-version.ts: -------------------------------------------------------------------------------- 1 | import Chalk from 'chalk' 2 | import type { Context } from '../../bin' 3 | 4 | export interface SelectVersionOptions { 5 | includePre: boolean 6 | includeDeprecated: boolean 7 | moduleTitle: string 8 | } 9 | 10 | export default async function selectVersion (ctx: Required, mod: string, version: string, options: SelectVersionOptions): Promise { 11 | const { spinner, npm } = ctx 12 | options = options ?? {} 13 | 14 | spinner.start(`finding ${options.moduleTitle} versions`) 15 | try { 16 | version = await npm.rangeToVersion(mod, version, options.includePre, options.includeDeprecated) 17 | } catch (err) { 18 | spinner.fail(`failed to find ${options.moduleTitle} versions`) 19 | throw err 20 | } 21 | spinner.succeed(`selected ${Chalk.bold(options.moduleTitle)} version ${Chalk.bold(version)}`) 22 | 23 | return version 24 | } 25 | -------------------------------------------------------------------------------- /src/commands/info.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import ora from 'ora' 3 | import { binPath, installPath } from '../default-paths.js' 4 | import info from '../lib/info.js' 5 | 6 | const help = ` 7 | iim info - Get info about the IPFS implementation currently in use. 8 | 9 | Usage: 10 | iim info [options...] 11 | 12 | Options: 13 | --help, -h Get help for the info command. 14 | 15 | Alias: 16 | i 17 | ` 18 | 19 | const options = {} 20 | 21 | export default { 22 | aliases: ['i'], 23 | help, 24 | options, 25 | run: async () => { 26 | const spinner = ora() 27 | const { 28 | implName, 29 | version, 30 | ipfsPath, 31 | implBinPath 32 | } = await info({ spinner }, binPath, installPath) 33 | 34 | console.log(`⚡️ version: ${implName} ${version}`) 35 | console.log(`📦 repo path: ${ipfsPath}`) 36 | console.log(`🏃‍♂️ bin path: ${implBinPath}`) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import help from './help.js' 2 | import info from './info.js' 3 | import link from './link.js' 4 | import list from './list.js' 5 | import use from './use.js' 6 | import version from './version.js' 7 | 8 | export interface ParseArgsOptionConfig { 9 | type: 'string' | 'boolean' 10 | multiple?: boolean 11 | short?: string 12 | default?: string | boolean | string[] | boolean[] 13 | } 14 | 15 | export interface Command { 16 | aliases: string[] 17 | help: string 18 | options: Record 19 | run(positionals: string[], options: any): Promise | void 20 | } 21 | 22 | const commands: Record = { 23 | help, 24 | info, 25 | link, 26 | list, 27 | use, 28 | version 29 | } 30 | 31 | // set up aliases 32 | for (const command of Object.values(commands)) { 33 | command.aliases.forEach(alias => { 34 | commands[alias] = command 35 | }) 36 | } 37 | 38 | export default commands 39 | -------------------------------------------------------------------------------- /src/commands/help.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const help = ` 3 | iim - Manage your IPFS installs. 4 | 5 | Usage: 6 | iim [options...] 7 | 8 | Command: 9 | use [version] Install and use an IPFS implementation. \`impl\` can be 10 | "js", "go" or "kubo" and \`version\` must be a valid semver 11 | version or range. 12 | info Get info about the IPFS implementation currently in use. 13 | link Symlink the current install as /usr/local/bin/ipfs 14 | list [impl] List the installed IPFS versions optionally filtered by 15 | implementation. 16 | version Print the version of this tool. 17 | 18 | Options: 19 | --help, -h Get help for a particular command. 20 | ` 21 | 22 | const options = {} 23 | 24 | export default { 25 | aliases: [], 26 | help, 27 | options, 28 | run: () => { 29 | console.info(help) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/commands/link.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import ora from 'ora' 3 | import { binPath, currentBinLinkPath } from '../default-paths.js' 4 | import link from '../lib/link.js' 5 | 6 | const help = ` 7 | iim link - Symlink the current install as /usr/local/bin/ipfs. 8 | 9 | If you accidentally remove or write over the symlink at /usr/local/bin/ipfs \`iim link\` will restore it, pointing it at the current IPFS binary. 10 | 11 | It's also useful in the case where a user does not have sufficient privileges to write to /usr/local/bin because it allows IPFS to be installed with a non-privileged account and then symlinked with \`sudo iim link\` afterwards. 12 | 13 | Usage: 14 | iim link 15 | 16 | Options: 17 | --help, -h Get help for the link command. 18 | ` 19 | 20 | const options = {} 21 | 22 | export default { 23 | aliases: [], 24 | help, 25 | options, 26 | run: async () => { 27 | const spinner = ora() 28 | await link({ spinner }, currentBinLinkPath, binPath) 29 | console.log('🔗 IPFS linked') 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/use/write-lib-bin.ts: -------------------------------------------------------------------------------- 1 | import Fs from 'node:fs/promises' 2 | import Os from 'node:os' 3 | import Path from 'node:path' 4 | import { fileURLToPath } from 'url' 5 | import type { Context } from '../../bin' 6 | 7 | const dirname = Path.dirname(fileURLToPath(import.meta.url)) 8 | 9 | export default async function writeLibBin (ctx: Context, destPath: string, ipfsBinPath: string, ipfsPath: string): Promise { 10 | const { spinner } = ctx 11 | const binTplPath = Path.join(dirname, '..', '..', 'bin', 'ipfs.tpl.js') 12 | 13 | ipfsPath = ipfsPath.replace(Os.homedir() + Path.sep, '') 14 | 15 | spinner.start(`installing binary at ${destPath}`) 16 | try { 17 | let content = await Fs.readFile(binTplPath, 'utf8') 18 | content = content.replace('{{IPFS_PATH}}', ipfsPath) 19 | content = content.replace('{{IPFS_BIN_PATH}}', ipfsBinPath) 20 | await Fs.writeFile(destPath, content, { mode: 0o755 }) 21 | } catch (err) { 22 | spinner.fail(`failed to install binary at ${destPath}`) 23 | throw err 24 | } 25 | spinner.succeed(`installed binary at ${destPath}`) 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alan Shaw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/commands/use.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import ora from 'ora' 3 | import { binPath, installPath, homePath, currentBinLinkPath } from '../default-paths.js' 4 | import Npm from '../lib/npm/index.js' 5 | import use from '../lib/use/index.js' 6 | import type { ParseArgsOptionConfig } from './index.js' 7 | 8 | const help = ` 9 | iim use - Install and use an IPFS implementation. 10 | 11 | Usage: 12 | iim use [version] [options...] 13 | 14 | Arguments: 15 | impl The implementation to use, currently supports "js", "go" and "kubo". 16 | version A valid semver version for the selected implementation. 17 | 18 | Options: 19 | --pre, -p Include pre-release versions. 20 | --help, -h Get help for the use command. 21 | 22 | Alias: 23 | u 24 | ` 25 | 26 | const options: Record = { 27 | pre: { 28 | short: 'p', 29 | type: 'boolean' 30 | }, 31 | deprecated: { 32 | short: 'd', 33 | type: 'boolean' 34 | } 35 | } 36 | 37 | export default { 38 | aliases: ['u'], 39 | help, 40 | options, 41 | run: async (positionals: string[], options: { pre: boolean, deprecated: boolean }) => { 42 | const [implName, versionRange] = positionals 43 | 44 | const spinner = ora() 45 | const npm = new Npm() 46 | await use({ npm, spinner }, implName, versionRange, options.pre, options.deprecated, binPath, installPath, homePath, currentBinLinkPath) 47 | 48 | console.log('🚀 IPFS is ready to use') 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/info.ts: -------------------------------------------------------------------------------- 1 | import Fs from 'node:fs/promises' 2 | import Os from 'node:os' 3 | import Path from 'node:path' 4 | import Url from 'node:url' 5 | // @ts-expect-error no types 6 | import explain from 'explain-error' 7 | import { readPackageUp } from 'read-package-up' 8 | import type { Context } from '../bin.js' 9 | 10 | export interface Info { 11 | implName: string 12 | version: string 13 | ipfsPath: string 14 | implBinPath: string 15 | } 16 | 17 | export default async (ctx: Context, binLinkPath: string, installPath: string): Promise => { 18 | const { spinner } = ctx 19 | 20 | spinner.start('fetching current version info') 21 | let binPath 22 | try { 23 | binPath = await Fs.readlink(binLinkPath) 24 | } catch (err) { 25 | spinner.fail() 26 | throw explain(err, 'failed to read IPFS symlink') 27 | } 28 | spinner.succeed() 29 | spinner.stop() 30 | 31 | if (!binPath.startsWith(installPath)) { 32 | throw new Error('unmanaged IPFS install') 33 | } 34 | 35 | const [implName, version] = binPath 36 | .replace(installPath, '') 37 | .split(Path.sep)[1] 38 | .split('@') 39 | 40 | const result = await readPackageUp({ cwd: Path.dirname(Url.fileURLToPath(import.meta.url)) }) 41 | 42 | if (result == null) { 43 | throw new Error('Could not read package.json') 44 | } 45 | 46 | const ipfsPath = Path.join(Os.homedir(), `.${result.packageJson.name}`, `${implName}@${version}`) 47 | 48 | return { implName, version, ipfsPath, implBinPath: binPath } 49 | } 50 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-console */ 3 | 4 | import { parseArgs } from 'node:util' 5 | import debug from 'debug' 6 | import commands, { type ParseArgsOptionConfig } from './commands/index.js' 7 | import type NpmLib from './lib/npm/index.js' 8 | import type { Ora } from 'ora' 9 | 10 | const log = debug('iim:bin') 11 | 12 | export interface Context { 13 | spinner: Ora 14 | npm?: NpmLib 15 | } 16 | 17 | const options: Record = { 18 | help: { 19 | type: 'boolean', 20 | short: 'h' 21 | } 22 | } 23 | 24 | const rootArgv = parseArgs({ args: process.argv, options, allowPositionals: true, strict: false }) 25 | 26 | const subcommand = commands[rootArgv.positionals[2]] 27 | 28 | if (subcommand == null) { 29 | await commands.help.run(rootArgv.positionals.slice(3), rootArgv.values) 30 | process.exit(0) 31 | } 32 | 33 | if (rootArgv.values.help === true) { 34 | console.info(subcommand.help) 35 | process.exit(0) 36 | } 37 | 38 | const subcommandArgv = parseArgs({ args: process.argv, options: subcommand.options, allowPositionals: true, strict: true }) 39 | 40 | await subcommand.run(subcommandArgv.positionals.slice(3), subcommandArgv.values) 41 | 42 | const logError = (err: Error): void => { 43 | console.error(`💥 ${err.message}`) 44 | log(err) 45 | } 46 | 47 | process.on('uncaughtException', (err: Error): void => { 48 | logError(err) 49 | }) 50 | process.on('unhandledRejection', (err: Error): void => { 51 | logError(err) 52 | process.exit(1) 53 | }) 54 | -------------------------------------------------------------------------------- /src/commands/list.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import Chalk from 'chalk' 3 | import ora from 'ora' 4 | import { binPath, installPath } from '../default-paths.js' 5 | import list from '../lib/list.js' 6 | import Npm from '../lib/npm/index.js' 7 | import type { ParseArgsOptionConfig } from './index.js' 8 | 9 | const help = ` 10 | iim list - List the installed IPFS versions. 11 | 12 | Usage: 13 | iim list [impl] [options...] 14 | 15 | Arguments: 16 | impl Filter the list by implementation name ("js" or "go"). 17 | 18 | Options: 19 | --remote, -r Include remote versions 20 | --deprecated, -d Include deprecated remote versions 21 | --help, -h Get help for the list command. 22 | 23 | Alias: 24 | ls 25 | ` 26 | 27 | const options: Record = { 28 | deprecated: { 29 | type: 'boolean', 30 | short: 'd' 31 | }, 32 | all: { 33 | type: 'boolean', 34 | short: 'a' 35 | } 36 | } 37 | 38 | export default { 39 | aliases: ['ls'], 40 | help, 41 | options, 42 | run: async (positionals: string[], options: any) => { 43 | const spinner = ora() 44 | const npm = new Npm() 45 | const versions = await list({ spinner, npm }, installPath, binPath, { 46 | implName: positionals[0], 47 | ...options 48 | }) 49 | 50 | if (versions.length === 0) { 51 | console.log('😱 no IPFS versions installed yet'); return 52 | } 53 | 54 | versions.forEach(({ implName, version, current, local }) => { 55 | let line = `${implName} ${version}` 56 | if (current != null) { 57 | line = `* ${Chalk.green(line)}` 58 | } else if (local == null) { 59 | line = ` ${Chalk.red(line)}` 60 | } else { 61 | line = ` ${line}` 62 | } 63 | console.log(line) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iim", 3 | "version": "0.7.0", 4 | "description": "IPFS install manager", 5 | "author": "Alan Shaw", 6 | "license": "Apache-2.0 OR MIT", 7 | "homepage": "https://github.com/alanshaw/iim#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/alanshaw/iim.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/alanshaw/iim/issues" 14 | }, 15 | "publishConfig": { 16 | "access": "public", 17 | "provenance": true 18 | }, 19 | "keywords": [ 20 | "IPFS", 21 | "install", 22 | "manager", 23 | "nave", 24 | "nvm", 25 | "version" 26 | ], 27 | "bin": { 28 | "iim": "dist/src/bin.js" 29 | }, 30 | "type": "module", 31 | "types": "./dist/src/index.d.ts", 32 | "files": [ 33 | "src", 34 | "dist", 35 | "!dist/test", 36 | "!**/*.tsbuildinfo" 37 | ], 38 | "exports": { 39 | ".": { 40 | "types": "./dist/src/index.d.ts", 41 | "import": "./dist/src/index.js" 42 | } 43 | }, 44 | "eslintConfig": { 45 | "extends": "ipfs", 46 | "parserOptions": { 47 | "project": true, 48 | "sourceType": "module" 49 | } 50 | }, 51 | "scripts": { 52 | "clean": "aegir clean", 53 | "test": "aegir test -t node", 54 | "test:node": "npm run test", 55 | "lint": "aegir lint", 56 | "build": "aegir build --bundle false", 57 | "docs": "aegir docs", 58 | "dep-check": "aegir dep-check", 59 | "release": "aegir release" 60 | }, 61 | "dependencies": { 62 | "chalk": "^5.3.0", 63 | "debug": "^4.1.1", 64 | "execa": "^8.0.1", 65 | "explain-error": "^1.0.4", 66 | "npm": "^6.0.0", 67 | "ora": "^7.0.1", 68 | "read-package-up": "^11.0.0", 69 | "semver": "^7.3.2", 70 | "sinon-ts": "^2.0.0" 71 | }, 72 | "devDependencies": { 73 | "aegir": "^41.2.0" 74 | }, 75 | "directories": { 76 | "test": "test" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/lib/use/npm-install.ts: -------------------------------------------------------------------------------- 1 | import Fs from 'node:fs/promises' 2 | import Path from 'node:path' 3 | import { execa } from 'execa' 4 | // @ts-expect-error no types 5 | import explain from 'explain-error' 6 | import type { Context } from '../../bin.js' 7 | 8 | export interface TargetVersion { 9 | update: boolean 10 | } 11 | 12 | export default async function npmInstall (ctx: Required, moduleName: string, version: string, path: string, options: TargetVersion): Promise { 13 | const { spinner, npm } = ctx 14 | options = options ?? {} 15 | 16 | spinner.start(`checking to see if ${moduleName}@${version} is already installed`) 17 | let isInstalled = false 18 | try { 19 | await Fs.stat(Path.join(path, 'node_modules', moduleName.replaceAll('@', '/'))) 20 | isInstalled = true 21 | } catch (err: any) { 22 | if (err.code !== 'ENOENT') { 23 | spinner.fail() 24 | throw explain(err, `failed to determine if ${moduleName} ${version} is already installed`) 25 | } 26 | await Fs.mkdir(path, { recursive: true }) 27 | } 28 | 29 | if (isInstalled && !options.update) { 30 | return isInstalled 31 | } 32 | 33 | spinner.text = `${isInstalled ? 'updating' : 'installing'} ${moduleName} ${version}` 34 | 35 | try { 36 | if (isInstalled) { 37 | await npm.update(path) 38 | } else { 39 | await npm.install(moduleName, version, path) 40 | 41 | // run postinstall if necessary 42 | const installDir = Path.join(path, 'node_modules', moduleName.replaceAll('@', '/')) 43 | 44 | const manifest = JSON.parse(await Fs.readFile(Path.join(installDir, 'package.json'), { 45 | encoding: 'utf-8' 46 | })) 47 | 48 | if (manifest.scripts.postinstall != null) { 49 | await execa('npm', ['run', 'postinstall'], { 50 | cwd: installDir 51 | }) 52 | } 53 | } 54 | } catch (err) { 55 | spinner.fail(`failed to ${isInstalled ? 'update' : 'install'} ${moduleName} ${version}`) 56 | throw err 57 | } 58 | spinner.succeed(`${isInstalled ? 'updated' : 'installed'} ${moduleName} ${version}`) 59 | 60 | return isInstalled 61 | } 62 | -------------------------------------------------------------------------------- /test/info.spec.ts: -------------------------------------------------------------------------------- 1 | import Fs from 'node:fs/promises' 2 | import Os from 'node:os' 3 | import Path from 'node:path' 4 | import { expect } from 'aegir/chai' 5 | import { readPackageUpSync } from 'read-package-up' 6 | import { stubInterface } from 'sinon-ts' 7 | import info from '../src/lib/info.js' 8 | import type { Ora } from 'ora' 9 | 10 | describe('info', () => { 11 | if (Os.platform() === 'win32') { 12 | it.skip('windows it not supported', () => {}) 13 | 14 | return 15 | } 16 | 17 | it('should get info', async () => { 18 | const spinner = stubInterface() 19 | const pkg = readPackageUpSync()?.packageJson ?? {} as any 20 | 21 | const binLinkPath = Path.join(Os.tmpdir(), `${pkg.name}${Math.random()}`) 22 | const installPath = `/usr/local/lib/${pkg.name}` 23 | const implName = `test-mod-${Math.random()}` 24 | const version = (Math.random() * 10).toFixed() + '.0.0' 25 | const ipfsPath = Path.join(Os.homedir(), `.${pkg.name}`, `${implName}@${version}`) 26 | const implBinPath = `${installPath}/${implName}@${version}/ipfs` 27 | 28 | await Fs.symlink(implBinPath, binLinkPath) 29 | 30 | const res = await info({ spinner }, binLinkPath, installPath) 31 | 32 | expect(res.implName).to.equal(implName) 33 | expect(res.version).to.equal(version) 34 | expect(res.ipfsPath).to.equal(ipfsPath) 35 | expect(res.implBinPath).to.equal(implBinPath) 36 | }) 37 | 38 | it('should throw if failed to read symlink', async () => { 39 | const spinner = stubInterface() 40 | const pkg = readPackageUpSync()?.packageJson ?? {} as any 41 | 42 | const binLinkPath = Path.join(Os.tmpdir(), `${pkg.name}${Math.random()}`) 43 | const installPath = `/usr/local/lib/${pkg.name}` 44 | 45 | await expect(info({ spinner }, binLinkPath, installPath)).to.eventually.be.rejected 46 | .with.property('message', 'failed to read IPFS symlink') 47 | }) 48 | 49 | it('should throw for unmanaged install', async () => { 50 | const spinner = stubInterface() 51 | const pkg = readPackageUpSync()?.packageJson ?? {} as any 52 | 53 | const binLinkPath = Path.join(Os.tmpdir(), `${pkg.name}${Math.random()}`) 54 | const installPath = `/usr/local/lib/${pkg.name}` 55 | const implBinPath = '/usr/bin/ipfs' 56 | 57 | await Fs.symlink(implBinPath, binLinkPath) 58 | 59 | await expect(info({ spinner }, binLinkPath, installPath)).to.eventually.be.rejected 60 | .with.property('message', 'unmanaged IPFS install') 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * 4 | * @example Use the latest version of Kubo 5 | * 6 | * ```sh 7 | * $ iim use kubo 8 | * ✔ selected kubo version 0.24.0 9 | * ✔ installed kubo 0.24.0 10 | * ✔ installed binary at /Users/alan/.iim/dists/kubo@0.24.0/ipfs 11 | * ✔ initialized IPFS at /Users/alan/.iim/kubo@0.24.0 12 | * ✔ configured IPFS 13 | * ✔ symlinked /Users/alan/.iim/dists/kubo@0.24.0/ipfs -> /Users/alan/.iim/dists/current 14 | * ✔ symlinked /Users/alan/.iim/dists/kubo@0.24.0/ipfs -> /usr/local/bin/ipfs 15 | * 🚀 IPFS is ready to use 16 | * 17 | * $ ipfs version 18 | * kubo version: 0.24.0 19 | * ``` 20 | * 21 | * @example Use Kubo at version 0.23.0 22 | * 23 | * ```sh 24 | * $ iim use kubo 0.23 25 | * ✔ selected kubo version 0.23.0 26 | * ✔ installed kubo 0.23.0 27 | * ✔ installed binary at /Users/alan/.iim/dists/kubo@0.23.0/ipfs 28 | * ✔ initialized IPFS at /Users/alan/.iim/kubo@0.23.0 29 | * ✔ configured IPFS 30 | * ✔ symlinked /Users/alan/.iim/dists/kubo@0.23.0/ipfs -> /Users/alan/.iim/dists/current 31 | * ✔ symlinked /Users/alan/.iim/dists/kubo@0.23.0/ipfs -> /usr/local/bin/ipfs 32 | * 🚀 IPFS is ready to use 33 | * 34 | * $ ipfs version 35 | * kubo version: 0.24.0 36 | *``` 37 | * 38 | * ## How does it work? 39 | * 40 | * A new repo is created and used for each implementation/version combination at `~/.iim/kubo@0.24.0`, for example. 41 | * 42 | * Adds a symlink at `/usr/local/bin/ipfs` that points to a script that runs IPFS with `IPFS_PATH` set to `~/.iim/kubo@0.24.0`. 43 | * 44 | * IPFS is installed to `~/.iim/dists/kubo@0.24.0/node_modules/ipfs` or `~/.iim/dists/go-ipfs@0.4.18/node_modules/go-ipfs-dep` for example. 45 | * 46 | * ## Common issues 47 | * 48 | * ### Failed to symlink 49 | * 50 | * Looks like this: 51 | * 52 | * ```sh 53 | * $ iim use kubo 54 | * ✔ selected kubo version 0.4.18 55 | * ✔ installed kubo 0.4.18 56 | * ✔ installed binary at /home/dave/.iim/dists/kubo@0.4.18/ipfs 57 | * ✔ initialized IPFS at /home/dave/.iim/kubo@0.4.18 58 | * ✔ symlinked /home/dave/.iim/dists/kubo@0.4.18/ipfs -> /home/dave/.iim/dists/current 59 | * ✖ failed to symlink /home/dave/.iim/dists/kubo@0.4.18/ipfs -> /usr/local/bin/ipfs 60 | * 💥 failed to link binary at /usr/local/bin/ipfs, try running sudo iim link 61 | * ``` 62 | * 63 | * Don't worry! Mostly everything worked fine - you just don't have permission to write to `/usr/local/bin`! Just run `sudo iim link` and it'll try again to create that symlink. 64 | * 65 | * Feel free to dive in! [Open an issue](https://github.com/alanshaw/iim/issues/new) or submit PRs. 66 | */ 67 | 68 | export {} 69 | -------------------------------------------------------------------------------- /src/lib/use/index.ts: -------------------------------------------------------------------------------- 1 | import Path from 'path' 2 | import Chalk from 'chalk' 3 | // @ts-expect-error no types 4 | import explain from 'explain-error' 5 | import { implementations } from '../../implementations.js' 6 | import configureNode from './configure-node.js' 7 | import ipfsInit from './ipfs-init.js' 8 | import npmInstall from './npm-install.js' 9 | import selectVersion from './select-version.js' 10 | import symlink from './symlink.js' 11 | import writeLibBin from './write-lib-bin.js' 12 | import type { Context } from '../../bin.js' 13 | 14 | export default async function use ( 15 | ctx: Required, 16 | implName: string, 17 | versionRange: string, 18 | includePre: boolean, 19 | includeDeprecated: boolean, 20 | binPath: string, 21 | installPath: string, 22 | homePath: string, 23 | currentBinLinkPath: string 24 | ): Promise { 25 | const { spinner, npm } = ctx 26 | 27 | if (implName == null) { 28 | throw new Error('missing implementation name') 29 | } 30 | 31 | if (!Object.keys(implementations).includes(implName)) { 32 | throw new Error(`unknown implementation ${implName}`) 33 | } 34 | 35 | // Select the version we want to use based on the input range 36 | const version = await selectVersion( 37 | { npm, spinner }, 38 | implementations[implName].moduleName, 39 | versionRange, 40 | { moduleTitle: `${implName}-ipfs`, includePre, includeDeprecated } 41 | ) 42 | 43 | const implInstallPath = Path.join(installPath, `${implName}-ipfs@${version}`) 44 | const isInstalled = await npmInstall( 45 | { npm, spinner }, 46 | implementations[implName].moduleName, 47 | version, 48 | implInstallPath, 49 | { update: implementations[implName].update } 50 | ) 51 | 52 | // /usr/local/bin/ipfs 53 | // -> ~/.iim/dists/js-ipfs@0.34.4/ipfs 54 | // -> ~/.iim/dists/js-ipfs@0.34.4/node_modules/.bin/jsipfs 55 | const libBinPath = Path.join(implInstallPath, 'ipfs') 56 | const implBinPath = Path.join(implInstallPath, implementations[implName].binPath) 57 | const ipfsPath = Path.join(homePath, `${implName}-ipfs@${version}`) 58 | 59 | await writeLibBin({ spinner }, libBinPath, implBinPath, ipfsPath) 60 | 61 | // If the version wasn't already installed we need to ipfs init 62 | if (!isInstalled) { 63 | await ipfsInit({ spinner }, libBinPath, ipfsPath) 64 | 65 | if (implementations[implName].configure) { 66 | await configureNode({ spinner }, libBinPath) 67 | } 68 | } 69 | 70 | // Make a note of which bin is current 71 | // ~/.iim/dists/current 72 | // -> ~/.iim/dists/js-ipfs@0.34.4/ipfs 73 | await symlink({ spinner }, libBinPath, currentBinLinkPath) 74 | 75 | try { 76 | await symlink({ spinner }, libBinPath, binPath) 77 | } catch (err) { 78 | throw explain(err, `failed to link binary at ${binPath}, try running ${Chalk.bold('sudo iim link')}`) 79 | } 80 | 81 | spinner.info(`using repo at ${ipfsPath}`) 82 | 83 | spinner.stop() 84 | } 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iim 2 | 3 | 4 | 5 | 6 | 7 | [![codecov](https://img.shields.io/codecov/c/github/alanshaw/iim.svg?style=flat-square)](https://codecov.io/gh/alanshaw/iim) 8 | [![CI](https://img.shields.io/github/actions/workflow/status/alanshaw/iim/js-test-and-release.yml?branch=master\&style=flat-square)](https://github.com/alanshaw/iim/actions/workflows/js-test-and-release.yml?query=branch%3Amaster) 9 | 10 | > IPFS install manager 11 | 12 | ## Install 13 | 14 | ```console 15 | $ npm i -g iim 16 | ``` 17 | 18 | Note: Windows not yet supported! 19 | 20 | ## Usage 21 | 22 | **Example - Use the latest version of Kubo** 23 | 24 | ```sh 25 | $ iim use kubo 26 | ✔ selected kubo version 0.24.0 27 | ✔ installed kubo 0.24.0 28 | ✔ installed binary at /Users/alan/.iim/dists/kubo@0.24.0/ipfs 29 | ✔ initialized IPFS at /Users/alan/.iim/kubo@0.24.0 30 | ✔ configured IPFS 31 | ✔ symlinked /Users/alan/.iim/dists/kubo@0.24.0/ipfs -> /Users/alan/.iim/dists/current 32 | ✔ symlinked /Users/alan/.iim/dists/kubo@0.24.0/ipfs -> /usr/local/bin/ipfs 33 | 🚀 IPFS is ready to use 34 | 35 | $ ipfs version 36 | kubo version: 0.24.0 37 | ``` 38 | 39 | **Example - Use Kubo at version 0.23.0** 40 | 41 | ```sh 42 | $ iim use kubo 0.23 43 | ✔ selected kubo version 0.23.0 44 | ✔ installed kubo 0.23.0 45 | ✔ installed binary at /Users/alan/.iim/dists/kubo@0.23.0/ipfs 46 | ✔ initialized IPFS at /Users/alan/.iim/kubo@0.23.0 47 | ✔ configured IPFS 48 | ✔ symlinked /Users/alan/.iim/dists/kubo@0.23.0/ipfs -> /Users/alan/.iim/dists/current 49 | ✔ symlinked /Users/alan/.iim/dists/kubo@0.23.0/ipfs -> /usr/local/bin/ipfs 50 | 🚀 IPFS is ready to use 51 | 52 | $ ipfs version 53 | kubo version: 0.23.0 54 | ``` 55 | 56 | ## How does it work? 57 | 58 | A new repo is created and used for each implementation/version combination at `~/.iim/kubo@0.24.0`, for example. 59 | 60 | Adds a symlink at `/usr/local/bin/ipfs` that points to a script that runs IPFS with `IPFS_PATH` set to `~/.iim/kubo@0.24.0`. 61 | 62 | IPFS is installed to `~/.iim/dists/kubo@0.24.0/node_modules/ipfs` or `~/.iim/dists/go-ipfs@0.4.18/node_modules/go-ipfs-dep` for example. 63 | 64 | ## Common issues 65 | 66 | ### Failed to symlink 67 | 68 | Looks like this: 69 | 70 | ```sh 71 | $ iim use kubo 72 | ✔ selected kubo version 0.4.18 73 | ✔ installed kubo 0.4.18 74 | ✔ installed binary at /home/dave/.iim/dists/kubo@0.4.18/ipfs 75 | ✔ initialized IPFS at /home/dave/.iim/kubo@0.4.18 76 | ✔ symlinked /home/dave/.iim/dists/kubo@0.4.18/ipfs -> /home/dave/.iim/dists/current 77 | ✖ failed to symlink /home/dave/.iim/dists/kubo@0.4.18/ipfs -> /usr/local/bin/ipfs 78 | 💥 failed to link binary at /usr/local/bin/ipfs, try running sudo iim link 79 | ``` 80 | 81 | Don't worry! Mostly everything worked fine - you just don't have permission to write to `/usr/local/bin`! Just run `sudo iim link` and it'll try again to create that symlink. 82 | 83 | Feel free to dive in! [Open an issue](https://github.com/alanshaw/iim/issues/new) or submit PRs. 84 | 85 | ## License 86 | 87 | [MIT](LICENSE) © Alan Shaw 88 | -------------------------------------------------------------------------------- /src/lib/npm/index.ts: -------------------------------------------------------------------------------- 1 | import Path from 'node:path' 2 | import { promisify } from 'node:util' 3 | import { fileURLToPath } from 'url' 4 | import debug from 'debug' 5 | import { execa } from 'execa' 6 | // @ts-expect-error no types 7 | import Npm from 'npm' 8 | import Semver from 'semver' 9 | 10 | const dirname = Path.dirname(fileURLToPath(import.meta.url)) 11 | 12 | const log = debug('iim:lib:npm') 13 | 14 | export interface GetVersionsOptions { 15 | deprecated?: boolean 16 | } 17 | 18 | export default class NpmLib { 19 | private _npm?: Npm.Static 20 | 21 | async _getNpm (): Promise { 22 | if (this._npm != null) { 23 | return this._npm 24 | } 25 | 26 | this._npm = await promisify(Npm.load)({ loglevel: 'silent', progress: false }) 27 | 28 | return this._npm 29 | } 30 | 31 | async getVersions (mod: string, options?: GetVersionsOptions): Promise { 32 | log(`npm view ${mod} time`) 33 | 34 | const req = await fetch(`https://registry.npmjs.org/${mod}`) 35 | const body = await req.json() 36 | 37 | const versions: string[] = [] 38 | const registryVersions: Array<[string, any]> = Object.entries(body.versions) 39 | 40 | for (const [version, metadata] of registryVersions) { 41 | if (Semver.valid(version) == null) { 42 | continue 43 | } 44 | 45 | if (metadata.deprecated != null && options?.deprecated !== true) { 46 | continue 47 | } 48 | 49 | versions.push(version) 50 | } 51 | 52 | return versions 53 | } 54 | 55 | async rangeToVersion (mod: string, range: string, includePre: boolean, includeDeprecated: boolean): Promise { 56 | const allVers = await this.getVersions(mod, { 57 | deprecated: includeDeprecated 58 | }) 59 | 60 | if (allVers.length === 0) { 61 | throw new Error(`${mod} has no versions to select from. Some may be deprecated, pass --deprecated as a flag to enabled their use`) 62 | } 63 | 64 | let rangeVers = includePre 65 | ? allVers 66 | : allVers.filter(v => Semver.prerelease(v) == null) 67 | 68 | if (range != null) { 69 | // If the user provides 1 or 1.2, we want to range-ify it to ^1 or ^1.2 70 | const parts = range.split('.') 71 | if (parts.length < 3 && parts.every(p => /^[0-9]+$/.test(p))) { 72 | range = `^${range}` 73 | } 74 | 75 | if (Semver.validRange(range, true) == null) { 76 | throw new Error(`invalid version or range "${range}"`) 77 | } 78 | 79 | log('range', range) 80 | 81 | // Filter to versions in the given range 82 | rangeVers = allVers.filter(v => Semver.satisfies(v, range, true)) 83 | } 84 | 85 | log('versions', allVers.join(', ')) 86 | 87 | if (rangeVers.length === 0) { 88 | throw new Error('no versions found within range') 89 | } 90 | 91 | // Get the top most ranking version available 92 | return rangeVers.reduce((top, v) => Semver.gt(v, top) ? v : top, rangeVers[0]) 93 | } 94 | 95 | async install (mod: string, version: string, path: string): Promise { 96 | await execa('node', [Path.join(dirname, 'bin', 'install'), `${mod}@${version}`], { cwd: path }) 97 | } 98 | 99 | async update (path: string): Promise { 100 | await execa('node', [Path.join(dirname, 'bin', 'update')], { cwd: path }) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/lib/list.ts: -------------------------------------------------------------------------------- 1 | import Fs from 'node:fs/promises' 2 | import debug from 'debug' 3 | // @ts-expect-error no types 4 | import explain from 'explain-error' 5 | import Semver from 'semver' 6 | import { implementations } from '../implementations.js' 7 | import info, { type Info } from './info.js' 8 | import type { Context } from '../bin.js' 9 | 10 | const log = debug('iim:lib:list') 11 | 12 | export interface Version { 13 | implName: string 14 | version: string 15 | current?: boolean 16 | local?: boolean 17 | } 18 | 19 | interface RemoteVersion { 20 | implName: string 21 | moduleName: string 22 | versions: string[] 23 | } 24 | 25 | export interface ListOptions { 26 | all?: boolean 27 | deprecated?: boolean 28 | implName?: string 29 | } 30 | 31 | export default async (ctx: Required, installPath: string, binLinkPath: string, options: ListOptions = {}): Promise => { 32 | const { spinner, npm } = ctx 33 | 34 | if (options?.implName != null && implementations[options.implName] == null) { 35 | throw new Error(`unknown implementation ${options.implName}`) 36 | } 37 | 38 | let currentVersionInfo: Info | undefined 39 | try { 40 | currentVersionInfo = await info(ctx, binLinkPath, installPath) 41 | } catch (err) { 42 | log(err) // Not fatal, continue 43 | } 44 | 45 | spinner.start('fetching local versions') 46 | let files: string[] 47 | try { 48 | files = await Fs.readdir(installPath) 49 | } catch (err: any) { 50 | if (err.code !== 'ENOENT') { 51 | spinner.fail() 52 | throw explain(err, 'failed to read local versions') 53 | } 54 | files = [] 55 | } 56 | spinner.succeed() 57 | 58 | const byImplName = ({ implName }: { implName: string }): boolean => ( 59 | options?.implName != null ? implName.startsWith(options.implName) : true 60 | ) 61 | 62 | const localVersions: Version[] = files 63 | .filter(f => f.includes('@')) 64 | .map(f => { 65 | const [implName, version] = f.split('@') 66 | return { implName, version, local: true } 67 | }) 68 | .filter(byImplName) 69 | 70 | log('local', localVersions) 71 | 72 | if (options.all == null) { 73 | return flagCurrent(currentVersionInfo, localVersions.sort(sortImplNameVersion)) 74 | } 75 | 76 | const remoteModules: RemoteVersion[] = Object.entries(implementations) 77 | .map(entry => ({ implName: entry[1].moduleName, moduleName: entry[1].moduleName, versions: [] })) 78 | .filter(byImplName) 79 | 80 | for (let i = 0; i < remoteModules.length; i++) { 81 | spinner.start(`fetching remote ${remoteModules[i].implName} versions`) 82 | try { 83 | remoteModules[i].versions = await npm.getVersions(remoteModules[i].moduleName, { 84 | deprecated: options.deprecated 85 | }) 86 | } catch (err) { 87 | spinner.fail() 88 | throw explain(err, `failed to get ${remoteModules[i].implName} versions`) 89 | } 90 | spinner.succeed() 91 | } 92 | 93 | const remoteVersions = remoteModules 94 | .reduce((versions, remoteModule) => { 95 | const moduleVersions = remoteModule.versions 96 | .map(v => ({ 97 | implName: remoteModule.implName, 98 | version: v 99 | })) 100 | return versions.concat(moduleVersions) 101 | }, []) 102 | .filter((remoteVersion) => { 103 | return !localVersions.some(localVersion => { 104 | return localVersion.implName === remoteVersion.implName && 105 | localVersion.version === remoteVersion.version 106 | }) 107 | }) 108 | 109 | log('remote', remoteVersions) 110 | 111 | return flagCurrent(currentVersionInfo, localVersions.concat(remoteVersions).sort(sortImplNameVersion)) 112 | } 113 | 114 | function sortImplNameVersion (a: Version, b: Version): number { 115 | if (a.implName === b.implName) { 116 | if (a.version === b.version) return 0 117 | return Semver.gt(a.version, b.version) ? -1 : 1 118 | } else { 119 | return a.implName.localeCompare(b.implName) 120 | } 121 | } 122 | 123 | function flagCurrent (currentVersionInfo: Info | undefined, versions: Version[]): Version[] { 124 | if (currentVersionInfo == null) { 125 | return versions 126 | } 127 | 128 | return versions.map(v => { 129 | if (v.implName === currentVersionInfo.implName && 130 | v.version === currentVersionInfo.version) { 131 | v.current = true 132 | } 133 | return v 134 | }) 135 | } 136 | --------------------------------------------------------------------------------