[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true"
  },
  {
    "path": ".eslintrc.yml",
    "content": "extends:\n  - gplane\n  - gplane/node\n  - gplane/typescript\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: g-plane\n"
  },
  {
    "path": ".gitignore",
    "content": "dist/\nnode_modules/\nyarn-error.log\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"eslint.validate\": [\n    \"javascript\",\n    \"javascriptreact\",\n    {\n      \"language\": \"typescript\",\n      \"autoFix\": true\n    }\n  ]\n}\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2018-present Pig Fang\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Tiny Package Manager\n\n> A very very simple demo and guide for explaining package manager.\n\n## Introduction\n\nAs a JavaScript developer, you may use package manager like [npm](https://www.npmjs.com/) or [Yarn](https://yarnpkg.com/)\nfrequently.\n\nHowever, do you know how a package manager works? Or, you may be curious about how to build a package manager.\n\nWell, the purpose of this guide is not to let you re-invent a new wheel.\nThere is no need to do that because both npm and Yarn are mature and stable enough.\nThe purpose is just to explain how a package manager works under the hood.\nYou can read the code, and the comments will explain how it works.\n\nNote: To simplify the guide and make it as simple as possible,\nthis demo doesn't handle some edge cases and catch errors and exceptions.\nIf you are really curious about that,\nit's recommended to read the source code of [npm](https://github.com/npm/npm) or [Yarn](https://github.com/yarnpkg/yarn).\n\n## Features\n\n- [x] Download packages to `node_modules` directory.\n- [x] Simple CLI.\n- [x] Simply resolve dependency conflicts.\n- [x] Flatten dependencies tree.\n- [x] Support lock file. (Like `yarn.lock` or `package-lock.json`)\n- [x] Add a new package through CLI. (Like `yarn add` or `npm i <package>` command)\n- [ ] Run lifecycle scripts. (`preinstall` and `postinstall`)\n- [ ] Symlink the `bin` files.\n\n## How to start?\n\nRead the source code in the `src` directory.\nYou can read the `src/index.ts` file in the beginning.\n\nIf you would like to try this simple package manager,\njust install it globally:\n\nVia Yarn:\n\n```\n$ yarn global add tiny-package-manager\n```\n\nVia npm:\n\n```\n$ npm i -g tiny-package-manager\n```\n\nThen just go to a directory which contains valid `package.json` and run:\n\n```\n$ tiny-pm\n```\n\n## License\n\nMIT License (c) 2018-present [Pig Fang](https://gplane.win/)\n"
  },
  {
    "path": "azure-pipelines.yml",
    "content": "pool:\n  vmImage: 'Ubuntu-16.04'\n\nsteps:\n- task: NodeTool@0\n  inputs:\n    versionSpec: '10.x'\n  displayName: 'Install Node.js'\n\n- script: |\n    yarn\n    yarn test\n  displayName: 'Run tests'\n"
  },
  {
    "path": "dprint.json",
    "content": "{\n  \"extends\": \"https://dprint.gplane.win/2024-01.json\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"tiny-package-manager\",\n  \"version\": \"1.1.0\",\n  \"description\": \"The tiny package manager.\",\n  \"main\": \"dist/index.js\",\n  \"repository\": \"g-plane/tiny-package-manager\",\n  \"author\": \"Pig Fang <g-plane@hotmail.com>\",\n  \"license\": \"MIT\",\n  \"private\": false,\n  \"bin\": {\n    \"tiny-pm\": \"dist/cli.js\"\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"scripts\": {\n    \"dev\": \"tsc -p . -w\",\n    \"build\": \"tsc -p .\",\n    \"prepublishOnly\": \"npm run build\",\n    \"lint\": \"eslint . -f=beauty --ext=.ts\",\n    \"test\": \"npm run lint\",\n    \"fmt\": \"dprint fmt\"\n  },\n  \"engines\": {\n    \"node\": \">8\"\n  },\n  \"dependencies\": {\n    \"find-up\": \"^5.0.0\",\n    \"fs-extra\": \"^11.1.0\",\n    \"js-yaml\": \"^4.1.0\",\n    \"log-update\": \"^4.0.0\",\n    \"progress\": \"^2.0.3\",\n    \"semver\": \"^7.3.8\",\n    \"tar\": \"^6.1.13\",\n    \"undici\": \"^5.26.4\",\n    \"yargs\": \"^17.6.2\"\n  },\n  \"devDependencies\": {\n    \"@gplane/tsconfig\": \"^5.0.0\",\n    \"@types/fs-extra\": \"^11.0.1\",\n    \"@types/js-yaml\": \"^4.0.5\",\n    \"@types/progress\": \"^2.0.5\",\n    \"@types/semver\": \"^7.3.13\",\n    \"@types/tar\": \"^6.1.3\",\n    \"@types/yargs\": \"^17.0.20\",\n    \"@typescript-eslint/eslint-plugin\": \"^5.49.0\",\n    \"@typescript-eslint/parser\": \"^5.49.0\",\n    \"dprint\": \"^0.45.0\",\n    \"eslint\": \"^8.33.0\",\n    \"eslint-config-gplane\": \"^6.2.2\",\n    \"eslint-formatter-beauty\": \"^3.0.0\",\n    \"typescript\": \"^4.9.4\"\n  }\n}\n"
  },
  {
    "path": "src/cli.ts",
    "content": "import yargs from 'yargs'\nimport pm from '.'\n\n/*\n * This file is for CLI usage.\n * There isn't too much logic about package manager here.\n * For details please consult the documentation of `yargs` module.\n */\n\nyargs\n  .usage('tiny-pm <command> [args]')\n  .version()\n  .alias('v', 'version')\n  .help()\n  .alias('h', 'help')\n  .command(\n    'install',\n    'Install the dependencies.',\n    (argv) => {\n      argv.option('production', {\n        type: 'boolean',\n        description: 'Install production dependencies only.',\n      })\n\n      argv.boolean('save-dev')\n      argv.boolean('dev')\n      argv.alias('D', 'dev')\n\n      return argv\n    },\n    pm\n  )\n  .command(\n    '*',\n    'Install the dependencies.',\n    (argv) =>\n      argv.option('production', {\n        type: 'boolean',\n        description: 'Install production dependencies only.',\n      }),\n    pm\n  )\n  .parse()\n"
  },
  {
    "path": "src/index.ts",
    "content": "import findUp from 'find-up'\nimport * as fs from 'fs-extra'\nimport type yargs from 'yargs'\nimport install from './install'\nimport list, { PackageJson } from './list'\nimport * as lock from './lock'\nimport * as log from './log'\nimport * as utils from './utils'\n\n/**\n * Welcome to learning about how to build a package manager.\n * In this guide I will tell you how to build a\n * very very simple package manager like npm or Yarn.\n *\n * I will use ES2017 syntax in this guide,\n * so please make sure you know about it.\n *\n * Also this guide is written in TypeScript.\n * Don't worry if you don't know TypeScript,\n * just treat it as JavaScript with some type annotations.\n * If you have learned Flow, that's great,\n * because they are similar.\n *\n * To make this guide as simple as possible,\n * I haven't handled some edge cases.\n *\n * Good luck and let's start!   :)\n *\n * This is just the main file of the whole tiny package manager,\n * but not all of the logic,\n * because I split them into different modules and files for better management.\n */\n\nexport default async function(args: yargs.Arguments) {\n  // Find and read the `package.json`.\n  const jsonPath = (await findUp('package.json'))!\n  const root = await fs.readJson(jsonPath)\n\n  /*\n   * If we are adding new packages by running `tiny-pm install <packageName>`,\n   * collect them through CLI arguments.\n   * This purpose is to behaves like `npm i <packageName>` or `yarn add`.\n   */\n  const additionalPackages = args._.slice(1)\n  if (additionalPackages.length) {\n    if (args['save-dev'] || args.dev) {\n      root.devDependencies = root.devDependencies || {}\n      /*\n       * At this time we don't specific version now, so set it empty.\n       * And we will fill it later after fetched the information.\n       */\n      additionalPackages.forEach((pkg) => (root.devDependencies[pkg] = ''))\n    } else {\n      root.dependencies = root.dependencies || {}\n      /*\n       * At this time we don't specific version now, so set it empty.\n       * And we will fill it later after fetched the information.\n       */\n      additionalPackages.forEach((pkg) => (root.dependencies[pkg] = ''))\n    }\n  }\n\n  /*\n   * In production mode,\n   * we just need to resolve production dependencies.\n   */\n  if (args.production) {\n    delete root.devDependencies\n  }\n\n  // Read the lock file\n  await lock.readLock()\n\n  // Generate the dependencies information.\n  const info = await list(root)\n\n  // Save the lock file asynchronously.\n  lock.writeLock()\n\n  /*\n   * Prepare for the progress bar.\n   * Note that we re-compute the number of packages.\n   * Because of the duplication,\n   * number of resolved packages is not equivalent to\n   * the number of packages to be installed.\n   */\n  log.prepareInstall(\n    Object.keys(info.topLevel).length + info.unsatisfied.length\n  )\n\n  // Install top level packages.\n  await Promise.all(\n    Object.entries(info.topLevel).map(([name, { url }]) => install(name, url))\n  )\n\n  // Install packages which have conflicts.\n  await Promise.all(\n    info.unsatisfied.map((item) => install(item.name, item.url, `/node_modules/${item.parent}`))\n  )\n\n  beautifyPackageJson(root)\n\n  // Save the `package.json` file.\n  fs.writeJson(jsonPath, root, { spaces: 2 })\n\n  // That's all! Everything should be finished if no errors occurred.\n}\n\n/**\n * Beautify the `dependencies` field and `devDependencies` field.\n */\nfunction beautifyPackageJson(packageJson: PackageJson) {\n  if (packageJson.dependencies) {\n    packageJson.dependencies = utils.sortKeys(packageJson.dependencies)\n  }\n\n  if (packageJson.devDependencies) {\n    packageJson.devDependencies = utils.sortKeys(packageJson.devDependencies)\n  }\n}\n"
  },
  {
    "path": "src/install.ts",
    "content": "import * as fs from 'fs-extra'\nimport * as tar from 'tar'\nimport { request } from 'undici'\nimport * as log from './log'\n\nexport default async function(name: string, url: string, location = '') {\n  // Prepare for the directory which is for installation\n  const path = `${process.cwd()}${location}/node_modules/${name}`\n\n  // Create directories recursively.\n  await fs.mkdirp(path)\n\n  const response = await request(url)\n  /*\n   * The response body is a readable stream\n   * and the `tar.extract` accepts a readable stream,\n   * so we don't need to create a file to disk,\n   * and just extract the stuff directly.\n   */\n  response.body\n    ?.pipe(tar.extract({ cwd: path, strip: 1 }))\n    .on('close', log.tickInstalling) // Update the progress bar\n}\n"
  },
  {
    "path": "src/list.ts",
    "content": "import * as semver from 'semver'\nimport * as lock from './lock'\nimport * as log from './log'\nimport resolve from './resolve'\n\ninterface DependenciesMap {\n  [dependency: string]: string\n}\n// eslint-disable-next-line @typescript-eslint/no-type-alias\ntype DependencyStack = Array<{\n  name: string,\n  version: string,\n  dependencies: { [dep: string]: string },\n}>\nexport interface PackageJson {\n  dependencies?: DependenciesMap\n  devDependencies?: DependenciesMap\n}\n\n/*\n * The `topLevel` variable is to flatten packages tree\n * to avoid duplication.\n */\nconst topLevel: {\n  [name: string]: { url: string, version: string },\n} = Object.create(null)\n\n/*\n * However, there may be dependencies conflicts,\n * so this variable is for that.\n */\nconst unsatisfied: Array<{ name: string, parent: string, url: string }> = []\n\nasync function collectDeps(\n  name: string,\n  constraint: string,\n  stack: DependencyStack = [],\n) {\n  // Retrieve a single manifest by name from the lock.\n  const fromLock = lock.getItem(name, constraint)\n\n  /*\n   * Fetch the manifest information.\n   * If that manifest is not existed in the lock,\n   * fetch it from network.\n   */\n  const manifest = fromLock || (await resolve(name))\n\n  // Add currently resolving module to CLI\n  log.logResolving(name)\n\n  /*\n   * Use the latest version of a package\n   * while it will conform the semantic version.\n   * However, if no semantic version is specified,\n   * use the latest version.\n   */\n  const versions = Object.keys(manifest)\n  const matched = constraint\n    ? semver.maxSatisfying(versions, constraint)\n    : versions[versions.length - 1] // The last one is the latest.\n  if (!matched) {\n    throw new Error('Cannot resolve suitable package.')\n  }\n\n  const matchedManifest = manifest[matched]!\n\n  if (!topLevel[name]) {\n    /*\n     * If this package is not existed in the `topLevel` map,\n     * just put it.\n     */\n    topLevel[name] = { url: matchedManifest.dist.tarball, version: matched }\n  } else if (semver.satisfies(topLevel[name]!.version, constraint)) {\n    const conflictIndex = checkStackDependencies(name, matched, stack)\n    if (conflictIndex === -1) {\n      /*\n       * Remember to return this function to skip the dependencies checking.\n       * This may avoid dependencies circulation.\n       */\n      return\n    }\n    /*\n     * Because of the module resolution algorithm of Node.js,\n     * there may be some conflicts in the dependencies of dependency.\n     * How to check it? See the `checkStackDependencies` function below.\n     * ----------------------------\n     * We just need information of the previous **two** dependencies\n     * of the dependency which has conflicts.\n     * :(  Not sure if it's right.\n     */\n    unsatisfied.push({\n      name,\n      parent: stack\n        .map(({ name }) => name) // eslint-disable-line no-shadow\n        .slice(conflictIndex - 2)\n        .join('/node_modules/'),\n      url: matchedManifest.dist.tarball,\n    })\n  } else {\n    /*\n     * Yep, the package is already existed in that map,\n     * but it has conflicts because of the semantic version.\n     * So we should add a record.\n     */\n    unsatisfied.push({\n      name,\n      parent: stack.at(-1)!.name,\n      url: matchedManifest.dist.tarball,\n    })\n  }\n\n  // Don't forget to collect the dependencies of our dependencies.\n  const dependencies = matchedManifest.dependencies ?? {}\n\n  // Save the manifest to the new lock.\n  lock.updateOrCreate(`${name}@${constraint}`, {\n    version: matched,\n    url: matchedManifest.dist.tarball,\n    shasum: matchedManifest.dist.shasum,\n    dependencies,\n  })\n\n  /*\n   * Collect the dependencies of dependency,\n   * so it's time to be deeper.\n   */\n  if (dependencies) {\n    stack.push({\n      name,\n      version: matched,\n      dependencies,\n    })\n    await Promise.all(\n      Object.entries(dependencies)\n        // The filter below is to prevent dependency circulation\n        .filter(([dep, range]) => !hasCirculation(dep, range, stack))\n        .map(([dep, range]) => collectDeps(dep, range, stack.slice()))\n    )\n    stack.pop()\n  }\n\n  /*\n   * Return the semantic version range to\n   * add missing semantic version range in `package.json`.\n   */\n  if (!constraint) {\n    return { name, version: `^${matched}` }\n  }\n}\n\n/**\n * This function is to check if there are conflicts in the\n * dependencies of dependency, not the top level dependencies.\n */\nfunction checkStackDependencies(\n  name: string,\n  version: string,\n  stack: DependencyStack,\n) {\n  return stack.findIndex(({ dependencies }) => {\n    const semverRange = dependencies[name]\n    /*\n     * If this package is not as a dependency of another package,\n     * this is safe and we just return `true`.\n     */\n    if (!semverRange) {\n      return true\n    }\n\n    // Semantic version checking.\n    return semver.satisfies(version, semverRange)\n  })\n}\n\n/**\n * This function is to check if there is dependency circulation.\n *\n * If a package is existed in the stack and it satisfy the semantic version,\n * it turns out that there is dependency circulation.\n */\nfunction hasCirculation(name: string, range: string, stack: DependencyStack) {\n  return stack.some(\n    (item) => item.name === name && semver.satisfies(item.version, range)\n  )\n}\n\n/**\n * To simplify this guide,\n * We intend to support `dependencies` and `devDependencies` fields only.\n */\nexport default async function(rootManifest: PackageJson) {\n  /*\n   * For both production dependencies and development dependencies,\n   * if the package name and the semantic version are returned,\n   * we should add them to the `package.json` file.\n   * This is necessary when adding new packages.\n   */\n\n  // Process production dependencies\n  if (rootManifest.dependencies) {\n    ;(\n      await Promise.all(\n        Object.entries(rootManifest.dependencies).map((pair) => collectDeps(...pair))\n      )\n    )\n      .filter(Boolean)\n      .forEach(\n        (item) => (rootManifest.dependencies![item!.name] = item!.version)\n      )\n  }\n\n  // Process development dependencies\n  if (rootManifest.devDependencies) {\n    ;(\n      await Promise.all(\n        Object.entries(rootManifest.devDependencies).map((pair) => collectDeps(...pair))\n      )\n    )\n      .filter(Boolean)\n      .forEach(\n        (item) => (rootManifest.devDependencies![item!.name] = item!.version)\n      )\n  }\n\n  return { topLevel, unsatisfied }\n}\n"
  },
  {
    "path": "src/lock.ts",
    "content": "import * as fs from 'fs-extra'\nimport * as yaml from 'js-yaml'\nimport type { Manifest } from './resolve'\nimport * as utils from './utils'\n\n// Define the type of the lock tree.\ninterface Lock {\n  [index: string]: {\n    version: string,\n    url: string,\n    shasum: string,\n    dependencies: { [dependency: string]: string },\n  }\n}\n\n// ------------ The LOCK is here. ---------------------\n\n/*\n * Why we use two separated locks?\n * This is useful when removing packages.\n * When adding or removing packages,\n * the lock file can be updated automatically without any manual operations.\n */\n\n/*\n * This is the old lock.\n * The old lock is only for reading from the lock file,\n * so the old lock should be read only except reading the lock file.\n */\nconst oldLock: Lock = Object.create(null)\n\n/*\n * This is the new lock.\n * The new lock is only for writing to the lock file,\n * so the new lock should be written only except saving the lock file.\n */\nconst newLock: Lock = Object.create(null)\n\n// ----------------------------------------------------\n\n/**\n * Save the information of a package to the lock.\n * If that information is not existed in the lock, create it.\n * Otherwise, just update it.\n */\nexport function updateOrCreate(name: string, info: Lock[string]) {\n  // Create it if that information is not existed in the lock.\n  if (!newLock[name]) {\n    newLock[name] = Object.create(null)\n  }\n\n  // Then update it.\n  Object.assign(newLock[name]!, info)\n}\n\n/**\n * Retrieve the information of a package by name and it's semantic\n * version range.\n *\n * Note that we don't return the data directly.\n * That is, we just do format the data,\n * which make the data structure similar to npm registry.\n *\n * This can let us avoid changing the logic of the `collectDeps`\n * function in the `list` module.\n */\nexport function getItem(name: string, constraint: string): Manifest | null {\n  /*\n   * Retrieve an item by a key from the lock.\n   * The format of the key is similar and inspired by Yarn's `yarn.lock` file.\n   */\n  const item = oldLock[`${name}@${constraint}`]\n\n  // Return `null` instead of `undefined` if we cannot find that.\n  if (!item) {\n    return null\n  }\n\n  // Convert the data structure as the comment above.\n  return {\n    [item.version]: {\n      dependencies: item.dependencies,\n      dist: { shasum: item.shasum, tarball: item.url },\n    },\n  }\n}\n\n/**\n * Simply save the lock file.\n */\nexport async function writeLock() {\n  /*\n   * Sort the keys of the lock before save it.\n   * This is necessary because each time you use the package manager,\n   * the order will not be same.\n   * Sort it makes useful for git diff.\n   */\n  await fs.writeFile(\n    './tiny-pm.yml',\n    yaml.dump(utils.sortKeys(newLock), { noRefs: true })\n  )\n}\n\n/**\n * Simply read the lock file.\n * Skip it if we cannot find the lock file.\n */\nexport async function readLock() {\n  if (await fs.pathExists('./tiny-pm.yml')) {\n    Object.assign(\n      oldLock,\n      yaml.load(await fs.readFile('./tiny-pm.yml', 'utf-8'))\n    )\n  }\n}\n"
  },
  {
    "path": "src/log.ts",
    "content": "import logUpdate from 'log-update'\nimport ProgressBar from 'progress'\n\nlet progress: ProgressBar\n\n/**\n * Update currently resolved module.\n * This is similar to Yarn.\n */\nexport function logResolving(name: string) {\n  logUpdate(`[1/2] Resolving: ${name}`)\n}\n\n/**\n * Use a friendly progress bar.\n */\nexport function prepareInstall(count: number) {\n  logUpdate('[1/2] Finished resolving.')\n  progress = new ProgressBar('[2/2] Installing [:bar]', {\n    complete: '#',\n    total: count,\n  })\n}\n\n/**\n * This is for update the progress bar\n * once tarball extraction is finished.\n */\nexport function tickInstalling() {\n  progress.tick()\n}\n"
  },
  {
    "path": "src/resolve.ts",
    "content": "import { request } from 'undici'\n\n// Just type definition and this can be ignored.\nexport interface Manifest {\n  [version: string]: {\n    dependencies?: { [dep: string]: string },\n    dist: { shasum: string, tarball: string },\n  }\n}\n\n// This allows us use a custom npm registry.\nconst REGISTRY = process.env.REGISTRY || 'https://registry.npmjs.org/'\n\n/*\n * Use cache to prevent duplicated network request,\n * when asking the same package.\n */\nconst cache: { [dep: string]: Manifest } = Object.create(null)\n\nexport default async function(name: string): Promise<Manifest> {\n  /*\n   * If the requested package manifest is existed in cache,\n   * just return it directly.\n   */\n  const cached = cache[name]\n  if (cached) {\n    return cached\n  }\n\n  const response = await request(`${REGISTRY}${name}`)\n\n  const json = (await response.body.json()) as\n    | { error: string }\n    | { versions: Manifest }\n  if ('error' in json) {\n    throw new ReferenceError(`No such package: ${name}`)\n  }\n\n  // Add the manifest info to cache and return it.\n  return (cache[name] = json.versions)\n}\n"
  },
  {
    "path": "src/utils.ts",
    "content": "export function sortKeys<T extends { [key: string]: any }>(obj: T) {\n  return Object.fromEntries(\n    Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))\n  )\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"extends\": \"@gplane/tsconfig\",\n  \"compilerOptions\": {\n    \"target\": \"es2020\",\n    \"module\": \"commonjs\",\n    \"outDir\": \"dist\"\n  }\n}\n"
  }
]