├── .env.example ├── .github └── workflows │ ├── npm-build.yml │ └── npm-publish.yml ├── .gitignore ├── .idx └── dev.nix ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bin └── index.js ├── package.json ├── pnpm-lock.yaml ├── screenshot.png ├── src ├── commands │ ├── apps.js │ ├── auth.js │ ├── files.js │ ├── init.js │ ├── shell.js │ ├── sites.js │ └── subdomains.js ├── commons.js ├── crypto.js ├── executor.js ├── modules │ └── ErrorModule.js └── utils.js └── tests ├── login.test.js └── utils.test.js /.env.example: -------------------------------------------------------------------------------- 1 | # Default Puter's API 2 | PUTER_API_BASE='http://api.puter.localhost:4100' 3 | PUTER_BASE_URL='http://puter.localhost:4100' -------------------------------------------------------------------------------- /.github/workflows/npm-build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node on every push 2 | name: Build package 3 | 4 | on: push 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: ['18.x', '20.x', '23.x'] 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Install pnpm 16 | uses: pnpm/action-setup@v4 17 | with: 18 | version: 10 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'pnpm' 24 | - name: Install dependencies 25 | run: pnpm install 26 | - name: Run tests & coverage 27 | run: pnpm run coverage 28 | - name: Upload coverage reports to Codecov 29 | uses: codecov/codecov-action@v5 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | slug: HeyPuter/puter-cli 33 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Publish package 5 | 6 | on: push 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | - run: npm ci 17 | - run: npm test 18 | 19 | publish-npm: 20 | needs: build 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | registry-url: https://registry.npmjs.org/ 28 | - run: npm ci 29 | - run: npm publish 30 | env: 31 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dev 2 | .yarn/ 3 | !.yarn/releases 4 | .vscode/* 5 | !.vscode/launch.json 6 | !.vscode/*.code-snippets 7 | .idea/workspace.xml 8 | .idea/usage.statistics.xml 9 | .idea/shelf 10 | 11 | # deps 12 | node_modules/ 13 | 14 | # env 15 | .env 16 | .env.production 17 | 18 | # logs 19 | logs/ 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | pnpm-debug.log* 25 | lerna-debug.log* 26 | 27 | # misc 28 | .DS_Store 29 | 30 | # code coverage 31 | **/coverage/ 32 | -------------------------------------------------------------------------------- /.idx/dev.nix: -------------------------------------------------------------------------------- 1 | # To learn more about how to use Nix to configure your environment 2 | # see: https://developers.google.com/idx/guides/customize-idx-env 3 | { pkgs, ... }: { 4 | # Which nixpkgs channel to use. 5 | channel = "stable-24.05"; # or "unstable" 6 | # Use https://search.nixos.org/packages to find packages 7 | packages = [ 8 | # pkgs.go 9 | # pkgs.python311 10 | # pkgs.python311Packages.pip 11 | pkgs.nodejs_20 12 | pkgs.nodePackages.nodemon 13 | ]; 14 | # Sets environment variables in the workspace 15 | env = {}; 16 | idx = { 17 | # Search for the extensions you want on https://open-vsx.org/ and use "publisher.id" 18 | extensions = [ 19 | # "vscodevim.vim" 20 | ]; 21 | # Enable previews 22 | previews = { 23 | enable = true; 24 | previews = { 25 | # web = { 26 | # # Example: run "npm run dev" with PORT set to IDX's defined port for previews, 27 | # # and show it in IDX's web preview panel 28 | # command = ["npm" "run" "dev"]; 29 | # manager = "web"; 30 | # env = { 31 | # # Environment variables to set for your server 32 | # PORT = "$PORT"; 33 | # }; 34 | # }; 35 | }; 36 | }; 37 | # Workspace lifecycle hooks 38 | workspace = { 39 | # Runs when a workspace is first created 40 | onCreate = { 41 | # Example: install JS dependencies from NPM 42 | # npm-install = "npm install"; 43 | # Open editors for the following files by default, if they exist: 44 | default.openFiles = [ ".idx/dev.nix" "README.md" ]; 45 | }; 46 | # Runs when the workspace is (re)started 47 | onStart = { 48 | # Example: start a background task to watch and re-build backend code 49 | # watch-backend = "npm run watch-backend"; 50 | }; 51 | }; 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 4 | 5 | Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). 6 | 7 | #### [v1.8.0](https://github.com/HeyPuter/puter-cli/compare/v1.7.3...v1.8.0) 8 | 9 | - feat(files): add edit command to modify remote files with local editor [`220b07c`](https://github.com/HeyPuter/puter-cli/commit/220b07c79fa9e9ab2e0e668cfbd7e5260c9746a2) 10 | 11 | #### [v1.7.3](https://github.com/HeyPuter/puter-cli/compare/v1.7.2...v1.7.3) 12 | 13 | > 16 March 2025 14 | 15 | - fix: absolute remote path treated as relative in update #10 [`cb716a3`](https://github.com/HeyPuter/puter-cli/commit/cb716a37afdd9f552c53244a3eb63d7a649e244c) 16 | 17 | #### [v1.7.2](https://github.com/HeyPuter/puter-cli/compare/v1.7.1...v1.7.2) 18 | 19 | > 4 March 2025 20 | 21 | - fix: mv command for both rename/move files [`0b944c8`](https://github.com/HeyPuter/puter-cli/commit/0b944c8295f615427bd90d19a6058b2053c1b3dc) 22 | 23 | #### [v1.7.1](https://github.com/HeyPuter/puter-cli/compare/v1.7.0...v1.7.1) 24 | 25 | > 4 March 2025 26 | 27 | - fix: update command [`38ca9e8`](https://github.com/HeyPuter/puter-cli/commit/38ca9e824642cbf8b1ffdb0d2a6e5426f84c7371) 28 | - docs: update README [`6e658f6`](https://github.com/HeyPuter/puter-cli/commit/6e658f6a117ee1ba206768905d53fb61c78e136d) 29 | 30 | #### [v1.7.0](https://github.com/HeyPuter/puter-cli/compare/v1.6.1...v1.7.0) 31 | 32 | > 16 February 2025 33 | 34 | - feat: save auth token when login [`55b32b7`](https://github.com/HeyPuter/puter-cli/commit/55b32b7feca050902f4470f06af38f81d3299e6a) 35 | - fix: create app from host shell [`30e5028`](https://github.com/HeyPuter/puter-cli/commit/30e5028d831d26349e3ae2fc8e34921693b5702c) 36 | 37 | #### [v1.6.1](https://github.com/HeyPuter/puter-cli/compare/v1.6.0...v1.6.1) 38 | 39 | > 16 February 2025 40 | 41 | - fix: set subdomain when creating a site [`6329ed1`](https://github.com/HeyPuter/puter-cli/commit/6329ed1c766b8eb722ac8c03c9ffe61dbba4a66c) 42 | 43 | #### [v1.6.0](https://github.com/HeyPuter/puter-cli/compare/v1.5.7...v1.6.0) 44 | 45 | > 16 February 2025 46 | 47 | - feat: improve init command [`2bd51ee`](https://github.com/HeyPuter/puter-cli/commit/2bd51ee01d0636b7979a9f55d3c746287c3b512a) 48 | - chore: improve error message [`46fb3b0`](https://github.com/HeyPuter/puter-cli/commit/46fb3b063c1c74d0006138cd400b6ad207e784d9) 49 | - chore: update package repo [`f6960ed`](https://github.com/HeyPuter/puter-cli/commit/f6960ed6f8e6fb793a6b8340476ab22778b44c14) 50 | 51 | #### [v1.5.7](https://github.com/HeyPuter/puter-cli/compare/v1.5.6...v1.5.7) 52 | 53 | > 16 February 2025 54 | 55 | - chore: clean unused [`8477dc2`](https://github.com/HeyPuter/puter-cli/commit/8477dc294773c99270a83dc22ae602abe92621cf) 56 | 57 | #### [v1.5.6](https://github.com/HeyPuter/puter-cli/compare/v1.5.5...v1.5.6) 58 | 59 | > 15 February 2025 60 | 61 | - fix: default api values [`146eda5`](https://github.com/HeyPuter/puter-cli/commit/146eda560da02f21a04523333b615c9ee2372322) 62 | 63 | #### [v1.5.5](https://github.com/HeyPuter/puter-cli/compare/v1.5.4...v1.5.5) 64 | 65 | > 13 February 2025 66 | 67 | - fix: improve error handling [`d1fa91d`](https://github.com/HeyPuter/puter-cli/commit/d1fa91db09f08238c6be684ffe4688a0064f06cd) 68 | 69 | #### [v1.5.4](https://github.com/HeyPuter/puter-cli/compare/v1.5.3...v1.5.4) 70 | 71 | > 13 February 2025 72 | 73 | - Remove "subdomain deletion" under Known Issues in README.md [`#8`](https://github.com/HeyPuter/puter-cli/pull/8) 74 | - dev: add last-error command, context, and modules [`#7`](https://github.com/HeyPuter/puter-cli/pull/7) 75 | - fix: delete a subdomain error message [`ed676dc`](https://github.com/HeyPuter/puter-cli/commit/ed676dc9d1364fabf242098e142a84c305dff177) 76 | - ci: fix timezone issue [`8ee6a66`](https://github.com/HeyPuter/puter-cli/commit/8ee6a66db36f7ca00eb81a12c8bac094e057f534) 77 | - ci: simplify timezone check [`ecf3bb9`](https://github.com/HeyPuter/puter-cli/commit/ecf3bb98bec5c093c23772280e3ee52aab8f3e8f) 78 | 79 | #### [v1.5.3](https://github.com/HeyPuter/puter-cli/compare/v1.5.2...v1.5.3) 80 | 81 | > 7 February 2025 82 | 83 | - refactor: early return in fallback behavior [`#6`](https://github.com/HeyPuter/puter-cli/pull/6) 84 | - fix: ignore undefined appDir when listing sites [`#5`](https://github.com/HeyPuter/puter-cli/pull/5) 85 | - fix: use array args when calling DeleteSubdomain [`#4`](https://github.com/HeyPuter/puter-cli/pull/4) 86 | - Update README.md [`#3`](https://github.com/HeyPuter/puter-cli/pull/3) 87 | - fix: improve app uuid check [`49dcc7f`](https://github.com/HeyPuter/puter-cli/commit/49dcc7f1220a58987601e8054b0d4a450cd02afe) 88 | - fix: potential timezone issues [`ba0e4b1`](https://github.com/HeyPuter/puter-cli/commit/ba0e4b183efb82a0c252e5c6250d976f1efe19cd) 89 | 90 | #### [v1.5.2](https://github.com/HeyPuter/puter-cli/compare/v1.5.1...v1.5.2) 91 | 92 | > 6 February 2025 93 | 94 | - chore: clean unused [`e5c7fcc`](https://github.com/HeyPuter/puter-cli/commit/e5c7fcca3096b4b2c0659d84d8de63dce039a765) 95 | - chore: more details [`0a25a2f`](https://github.com/HeyPuter/puter-cli/commit/0a25a2fb7efa9a67033b4e483305da44a68f2c9a) 96 | - docs: badge version [`503bc66`](https://github.com/HeyPuter/puter-cli/commit/503bc6667666b14f85245887a3bf2071711dc4e1) 97 | 98 | #### [v1.5.1](https://github.com/HeyPuter/puter-cli/compare/v1.5.0...v1.5.1) 99 | 100 | > 5 February 2025 101 | 102 | - imporve listing uid [`e2573f8`](https://github.com/HeyPuter/puter-cli/commit/e2573f83df6b47d8ab32ffc66ab19a9c984dd250) 103 | 104 | #### [v1.5.0](https://github.com/HeyPuter/puter-cli/compare/v1.4.4...v1.5.0) 105 | 106 | > 5 February 2025 107 | 108 | - fix: improve version check [`f3ea79e`](https://github.com/HeyPuter/puter-cli/commit/f3ea79e3156632f892558489dfd34b1119948a48) 109 | - update README [`79b381c`](https://github.com/HeyPuter/puter-cli/commit/79b381c57935cae5ffb9edf5ad11aca395ae8f8d) 110 | 111 | #### [v1.4.4](https://github.com/HeyPuter/puter-cli/compare/v1.4.3...v1.4.4) 112 | 113 | > 4 February 2025 114 | 115 | - simplify failed login [`bbf8a42`](https://github.com/HeyPuter/puter-cli/commit/bbf8a429c1d2a64fbb03fb42f461bcc1a32c8379) 116 | 117 | #### [v1.4.3](https://github.com/HeyPuter/puter-cli/compare/v1.4.2...v1.4.3) 118 | 119 | > 2 February 2025 120 | 121 | - fix: show disk usage [`dfd0ca3`](https://github.com/HeyPuter/puter-cli/commit/dfd0ca302f459b7295593c8d0147df15e488c963) 122 | 123 | #### [v1.4.2](https://github.com/HeyPuter/puter-cli/compare/v1.4.1...v1.4.2) 124 | 125 | > 30 January 2025 126 | 127 | - license to MIT [`ed0c7ce`](https://github.com/HeyPuter/puter-cli/commit/ed0c7cefba242a5d8fe701cddf5adbe32121bca7) 128 | - codecov: setup & badge [`7c4211b`](https://github.com/HeyPuter/puter-cli/commit/7c4211b6ea7b882485e3dfbaa17887a2d1fabccc) 129 | - changelog: update license [`dcf8d23`](https://github.com/HeyPuter/puter-cli/commit/dcf8d232160cf74d13267baa07a8f36578df9443) 130 | 131 | #### [v1.4.1](https://github.com/HeyPuter/puter-cli/compare/v1.4.0...v1.4.1) 132 | 133 | > 27 January 2025 134 | 135 | - feat: init command will scaffold new project [`c61d3f8`](https://github.com/HeyPuter/puter-cli/commit/c61d3f8669d66eb1a869db38b877d226d00b8aca) 136 | - changelog: update [`c09aa17`](https://github.com/HeyPuter/puter-cli/commit/c09aa179bd0ad2f437bdf779916050c2496424cd) 137 | 138 | #### [v1.4.0](https://github.com/HeyPuter/puter-cli/compare/v1.3.0...v1.4.0) 139 | 140 | > 26 January 2025 141 | 142 | - offline mode to get version [`d3c1dc2`](https://github.com/HeyPuter/puter-cli/commit/d3c1dc275bf8a1f38f083c8cdb066fb884a08138) 143 | - changelog: update [`f0cc1e3`](https://github.com/HeyPuter/puter-cli/commit/f0cc1e36b2b246d65a88f32c626942c0e15ac245) 144 | 145 | #### [v1.3.0](https://github.com/HeyPuter/puter-cli/compare/v1.2.1...v1.3.0) 146 | 147 | > 22 January 2025 148 | 149 | - ci: simplify workflow [`757b41c`](https://github.com/HeyPuter/puter-cli/commit/757b41caa62dc71946e7f1ffb34a32f2871248e0) 150 | - test: setup coverage [`2dd6500`](https://github.com/HeyPuter/puter-cli/commit/2dd650088ad9ecb6f7f9cd60b3dab80d48ac2611) 151 | - ci: update packages [`b346932`](https://github.com/HeyPuter/puter-cli/commit/b346932c4b6af2d8e43279ad3f35c45e451fd9f0) 152 | 153 | #### [v1.2.1](https://github.com/HeyPuter/puter-cli/compare/v1.2.0...v1.2.1) 154 | 155 | > 18 January 2025 156 | 157 | - feat: update changelog [`028ca0c`](https://github.com/HeyPuter/puter-cli/commit/028ca0cf72e09bb63468d2b0a0ba7602d3b870ad) 158 | - feat: auto-update changelog [`3004bed`](https://github.com/HeyPuter/puter-cli/commit/3004beda6afcf68cc916d544a45be85fa7e658e3) 159 | 160 | #### [v1.2.0](https://github.com/HeyPuter/puter-cli/compare/v1.1.5...v1.2.0) 161 | 162 | > 18 January 2025 163 | 164 | - ci: attempt to fix npm install [`32fc508`](https://github.com/HeyPuter/puter-cli/commit/32fc508c4807119de485926674274b70e034288f) 165 | - feat: basic history command [`18da28c`](https://github.com/HeyPuter/puter-cli/commit/18da28c83aa0760128d7b18e66e6b4d2b08b48d3) 166 | - chore: improve structure [`70b67ad`](https://github.com/HeyPuter/puter-cli/commit/70b67adad5bd5e0bad4a9276160d17538d9b4bb6) 167 | 168 | #### [v1.1.5](https://github.com/HeyPuter/puter-cli/compare/v1.1.4...v1.1.5) 169 | 170 | > 16 January 2025 171 | 172 | - fix: description arg handling & improve args parsing [`45c5a8c`](https://github.com/HeyPuter/puter-cli/commit/45c5a8c19034379e3cd7f30e724b8675d98bf28f) 173 | 174 | #### [v1.1.4](https://github.com/HeyPuter/puter-cli/compare/v1.1.3...v1.1.4) 175 | 176 | > 16 January 2025 177 | 178 | - improve compatibility crypto api [`b015e6f`](https://github.com/HeyPuter/puter-cli/commit/b015e6f1318a2c4994675dd7390fab09d45bf3e9) 179 | - fix: temporary disabled description [`c7456be`](https://github.com/HeyPuter/puter-cli/commit/c7456bed1c496f1a52156801aa4c0ea0191279c7) 180 | 181 | #### [v1.1.3](https://github.com/HeyPuter/puter-cli/compare/v1.1.2...v1.1.3) 182 | 183 | > 14 January 2025 184 | 185 | - fix: unavailable subdomain when update app [`2497de4`](https://github.com/HeyPuter/puter-cli/commit/2497de41b5691df4d3a141952841c08cace4703c) 186 | 187 | #### [v1.1.2](https://github.com/HeyPuter/puter-cli/compare/v1.1.1...v1.1.2) 188 | 189 | > 14 January 2025 190 | 191 | - grab latest version from npm [`9c32190`](https://github.com/HeyPuter/puter-cli/commit/9c321906415dfb5baa3d2bbba7b352f2766f8b84) 192 | 193 | #### [v1.1.1](https://github.com/HeyPuter/puter-cli/compare/v1.1.0...v1.1.1) 194 | 195 | > 14 January 2025 196 | 197 | - Create npm-publish action [`48d9a70`](https://github.com/HeyPuter/puter-cli/commit/48d9a709417664900681e2219ea2af5e9bf33c01) 198 | - update README: badges [`49241d7`](https://github.com/HeyPuter/puter-cli/commit/49241d7144c8c128955891a64acb448e79e1822c) 199 | 200 | #### [v1.1.0](https://github.com/HeyPuter/puter-cli/compare/v1.0.3...v1.1.0) 201 | 202 | > 14 January 2025 203 | 204 | - feat: add support for 2FA logins [`#1`](https://github.com/HeyPuter/puter-cli/pull/1) 205 | 206 | #### [v1.0.3](https://github.com/HeyPuter/puter-cli/compare/v1.0.1...v1.0.3) 207 | 208 | > 13 January 2025 209 | 210 | - update README [`479b8fc`](https://github.com/HeyPuter/puter-cli/commit/479b8fc9c784061146f453bc68759dbdb417ea1e) 211 | 212 | #### v1.0.1 213 | 214 | > 13 January 2025 215 | 216 | - initial commit [`604d4a4`](https://github.com/HeyPuter/puter-cli/commit/604d4a47c8b593b7e24757c115df728f09233664) 217 | - output nicely formatted table [`a0adb75`](https://github.com/HeyPuter/puter-cli/commit/a0adb75813bcecb21878c8ae7228b0ecbfdb397f) 218 | - remove unused packages [`925b6cb`](https://github.com/HeyPuter/puter-cli/commit/925b6cbf827e619e65eb5afaa566a4d14e919cb8) 219 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ibrahim H. 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Puter-CLI 2 | 3 |

4 | test 5 | 6 | 7 | 8 | GitHub repo size 9 | 10 | License 11 | 12 | 13 |

14 | 15 | 16 | The **Puter CLI** is a command-line interface tool designed to interact with the **Puter Cloud Platform**. If you don't have an account you can [Signup](https://puter.com/?r=N5Y0ZYTF) from here for free. This cli tool allows users to manage files, directories, applications, and other resources directly from the terminal. This tool is ideal for developers and power users who prefer working with command-line utilities. 17 | 18 | 19 | ![](./screenshot.png) 20 | 21 | --- 22 | 23 | ## Features 24 | 25 | - **File Management**: Upload, download, list, and manage files and directories. 26 | - **Authentication**: Log in and log out of your Puter account. 27 | - **User Information**: Retrieve user details, such as username, email, and account status. 28 | - **Disk Usage**: Check disk usage and storage limits. 29 | - **Application Management**: Create, delete, and list applications hosted on Puter. 30 | - **Static Site Hosting**: Deploy static websites from directories. 31 | - **Interactive Shell**: Use an interactive shell for seamless command execution. 32 | - **Cross-Platform**: Works on Windows, macOS, and Linux. 33 | 34 | --- 35 | 36 | ## Installation 37 | 38 | ### Prerequisites 39 | - Node.js (v18 or higher) 40 | - npm (v7 or higher) 41 | 42 | Run the following command to install puter-cli globally in your system: 43 | ```bash 44 | npm install -g puter-cli 45 | ``` 46 | 47 | Execute the following command to check the installation process: 48 | ```bash 49 | puter --version 50 | ``` 51 | 52 | ## Usage 53 | 54 | ### Commands 55 | 56 | #### Initialize a project 57 | - **Create a new project**: Initialize a new project 58 | ```bash 59 | puter init 60 | ``` 61 | Then just follow the prompts, this command doesn't require you to log in. 62 | 63 | #### Authentication 64 | - **Login**: Log in to your Puter account. 65 | ```bash 66 | puter login [--save] 67 | ``` 68 | P.S. You can add `--save` to save your authentication `token` to `.env` file as `PUTER_API_KEY` variable. 69 | 70 | - **Logout**: Log out of your Puter account. 71 | ```bash 72 | puter logout 73 | ``` 74 | 75 | #### File Management 76 | 77 | We've adopted the most basic popular Linux system command line for daily file manipulation with some extra features, not out of the box though, we want to keep it simple. 78 | 79 | - **List Files**: List files and directories. 80 | ```bash 81 | puter> ls [dir] 82 | ``` 83 | - **Change Directory**: Navigate to a directory: 84 | ```bash 85 | puter> cd [dir] 86 | ``` 87 | It works with wildcards as you would expect in any OS for basic navigation with insensitive case: `cd ..`, `cd ../myapp`...etc. 88 | 89 | - **Create Directory**: Create a new directory. 90 | ```bash 91 | puter> mkdir 92 | ``` 93 | 94 | - **Create file**: Create a new file in the current directory. 95 | ```bash 96 | puter> touch 97 | ``` 98 | 99 | - **Copy Files**: Copy files or directories. 100 | ```bash 101 | puter> cp 102 | ``` 103 | 104 | - **Move Files**: Move or rename files or directories. 105 | ```bash 106 | puter> mv 107 | ``` 108 | 109 | - **Delete Files/Directories**: Move files or directories to the trash. 110 | ```bash 111 | puter> rm [-f] 112 | ``` 113 | 114 | - **Empty Trash**: Empty the system's trash. 115 | ```bash 116 | puter> clean 117 | ``` 118 | ##### Extra commands: 119 | 120 | Think of it as `git [push|pull]` commands, they're basically simplified equivalents. 121 | 122 | - **Push Files**: Copy files from host machine to the remote cloud instance. 123 | ```bash 124 | puter> push 125 | ``` 126 | - **Pull Files**: Copy files from remote cloud instance to the host machine. 127 | ```bash 128 | puter> pull 129 | ``` 130 | P.S. These commands consider the current directory as the base path for every operation, basic wildcards are supported: e.g. `push myapp/*.html`. 131 | 132 | - **Synchronize Files**: Bidirectional synchronization between local and remote directories. 133 | ```bash 134 | puter> update [--delete] [-r] 135 | ``` 136 | P.S. The `--delete` flag removes files in the remote directory that don't exist locally. The `-r` flag enables recursive synchronization of subdirectories. 137 | 138 | **Edit a file**: Edit remote text files using your preferred local text editor. 139 | ```bash 140 | puter> edit 141 | ``` 142 | P.S. This command will download the remote file to your local machine, open it in your default editor, and then upload the changes back to the remote instance. It uses `vim` by default, but you can change it by setting the `EDITOR` environment variable. 143 | 144 | #### User Information 145 | ``` 146 | 147 | The addition describes the `update` command which allows for bidirectional synchronization between local and remote directories, including the optional flags for deleting files and recursive synchronization. 148 | --- 149 | 150 | 151 | #### User Information 152 | - **Get User Info**: Display user information. 153 | ```bash 154 | puter> whoami 155 | ``` 156 | 157 | #### Disk Usage 158 | - **Check Disk Usage**: Display disk usage information. 159 | ```bash 160 | puter> df 161 | ``` 162 | - **Get Usage Info**: Fetch usage information for services. 163 | ```bash 164 | puter> usage 165 | ``` 166 | 167 | #### Application Management 168 | 169 | The **Application** are sepcial type of hosted web app, they're served from the special directory at: `/AppData/...`, more details at **app:create** in the section below. 170 | 171 | - **List Applications**: List all applications. 172 | ```bash 173 | puter> apps [period] 174 | ``` 175 | P.S. Please check the help command `help apps` for more details about any argument. 176 | 177 | - **Create Application**: Create a new application. 178 | ```bash 179 | puter> app:create [] [--description="My App Description"] [--url=] 180 | ``` 181 | - This command works also from your system's terminal: 182 | ```bash 183 | $> puter app:create [] [--description="My App Description"] [--url=] 184 | ``` 185 | 186 | P.S. By default a new `index.html` with basic content will be created, but you can set a directory when you create a new application as follows: `app:create nameOfApp ./appDir`, so all files will be copied to the `AppData` directory, you can then update your app using `app:update `. This command will attempt to create a subdomain with a random `uid` prefixed with the name of the app. 187 | 188 | 189 | - **Update Application**: Update an application. 190 | ```bash 191 | puter> app:update 192 | ``` 193 | **IMPORTANT** All existing files will be overwritten, new files are copied, other files are just ignored. 194 | 195 | - **Delete Application**: Delete an application. 196 | ```bash 197 | puter> app:delete [-f] 198 | ``` 199 | P.S. This command will look for the allocated `subdomain` and attempt to delete it if it exists. 200 | 201 | #### Static Sites 202 | 203 | The static sites are served from the selected directory (or the current directory if none is specified). 204 | 205 | - **Deploy Site**: Deploy a static website from a directory. 206 | ```bash 207 | puter> site:create [] [--subdomain=] 208 | ``` 209 | P.S. If the subdomain already exists, it will generate a new random one. You can set your own subdomain using `--subdomain` argument. 210 | 211 | - **List Sites**: List all hosted sites. 212 | ```bash 213 | puter> sites 214 | ``` 215 | - **Delete Site**: Delete a hosted site. 216 | ```bash 217 | puter> site:delete 218 | ``` 219 | P.S. You can find the `` in the list of `sites`. 220 | 221 | #### Commands history 222 | 223 | - **Display history**: Display the history executed commands 224 | ```bash 225 | puter> history 226 | ``` 227 | - **Copy command from history**: Copy a previous command from history by line number 228 | ```bash 229 | puter> history 230 | ``` 231 | 232 | #### Interactive Shell 233 | - **Start Shell**: Launch an interactive shell. 234 | ```bash 235 | puter [shell] 236 | ``` 237 | or just type (you'll need to login): 238 | ```bash 239 | puter 240 | ``` 241 | 242 | #### Help 243 | - **General Help**: Display a list of available commands. 244 | ```bash 245 | puter help 246 | # or inside the interactive shell: 247 | puter> help 248 | ``` 249 | - **Command Help**: Display detailed help for a specific command. 250 | ```bash 251 | puter help 252 | # or inside the interactive shell: 253 | puter> help 254 | ``` 255 | 256 | --- 257 | 258 | ## Examples 259 | 260 | 1. **Log in and List Files**: 261 | ```bash 262 | puter login 263 | puter> ls 264 | ``` 265 | 266 | 2. **Create and Deploy a Static Site**: 267 | ```bash 268 | puter> mkdir my-site 269 | puter> site:create my-site --subdomain=myapp 270 | ``` 271 | 272 | 3. **Check Disk Usage**: 273 | ```bash 274 | puter> df 275 | ``` 276 | 277 | 4. **Delete a File**: 278 | ```bash 279 | puter> rm /path/to/file 280 | ``` 281 | 282 | 5. **Display statistics**: 283 | ```bash 284 | puter> stat /path/to/file/or/directory 285 | ``` 286 | 287 | --- 288 | 289 | ## Development 290 | 291 | If you want to customize this tool you can follow these steps: 292 | 293 | ### Steps 294 | 1. Clone the repository: 295 | ```bash 296 | git clone https://github.com/HeyPuter/puter-cli.git 297 | cd puter-cli 298 | ``` 299 | 2. Install dependencies: 300 | ```bash 301 | npm install 302 | ``` 303 | 3. Set your own variable environnements: 304 | ``` 305 | cp .env.example .env 306 | # update your own values in .env file 307 | ``` 308 | 4. Link the CLI globally: 309 | ```bash 310 | npm link 311 | ``` 312 | 313 | --- 314 | 315 | ## Known issues: 316 | 317 | Most features are working fine. If you have any issues with this project or the Puter SDK, please let us know: 318 | 319 | ## Interactive Shell prompt: 320 | If you want to stay in the interactive shell you should provide "-f" (aka: force delete) argument, when want to delete any object: 321 | ```bash 322 | puter@username/myapp> rm README.md 323 | The following items will be moved to Trash: 324 | - /username/myapp/README.md 325 | ? Are you sure you want to move these 1 item(s) to Trash? (y/N) n 326 | puter@username/myapp> Operation canceled. 327 | username:~/home$ puter 328 | puter@username/myapp> rm -f README.md 329 | Successfully moved "/username/myapp/README.md" to Trash! 330 | ``` 331 | Otherwise, the Interactive Shell mode will be terminated. 332 | 333 | --- 334 | 335 | ## Notes 336 | 337 | This project is not equivalent [phoenix](https://github.com/HeyPuter/puter/blob/main/src/phoenix/README.md), neither an attempt to mimic some of its features, it's rather a CLI tool to do most the Puter's API from the command line. 338 | 339 | --- 340 | 341 | ## Configuration 342 | 343 | The CLI uses a configuration file to store user credentials and settings. You can use the `puter logout` to clear the configuration settings. 344 | 345 | --- 346 | 347 | ## Contributing 348 | 349 | We welcome contributions! Please follow these steps: 350 | 1. Fork the repository. 351 | 2. Create a new branch for your feature or bugfix with reproducible steps. 352 | 3. Submit a pull request with a detailed description of your changes. 353 | 354 | --- 355 | 356 | ## License 357 | 358 | As of version v1.4.2, this project is licensed under the **[MIT License](LICENSE.md)**. 359 | 360 | ## NoHarm 361 | 362 | This project was previously licensed under the "NoHarm" license to explicitly prevent its use for harmful purposes. While it is now licensed under the permissive MIT License to encourage broader usage and contribution, we strongly emphasize that this software should not be used in any way that causes harm, infringes on others' rights, or promotes unethical practices. By using this software, you are urged to adhere to these principles and use it responsibly. 363 | 364 | 365 | --- 366 | 367 | ## Support 368 | 369 | For issues or questions, please open an issue on [GitHub](https://github.com/HeyPuter/puter-cli/issues) or contact [puter's team](mailto:hey@puter.com) if you found an issue related to Puter's APIs. 370 | 371 | --- 372 | 373 | ## Acknowledgments 374 | 375 | - **Puter Cloud Platform** for providing the backend infrastructure. 376 | - **Node.js** and **npm** for enabling this project. 377 | - The open-source community for their invaluable contributions. 378 | 379 | --- 380 | 381 | 382 | Happy deploying with **Puter CLI**! 🚀 383 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Command } from 'commander'; 3 | import chalk from 'chalk'; 4 | import { login, logout } from '../src/commands/auth.js'; 5 | import { init } from '../src/commands/init.js'; 6 | import { startShell } from '../src/commands/shell.js'; 7 | import { PROJECT_NAME, getLatestVersion } from '../src/commons.js'; 8 | import { createApp } from '../src/commands/apps.js'; 9 | 10 | async function main() { 11 | const version = await getLatestVersion(PROJECT_NAME); 12 | 13 | const program = new Command(); 14 | program 15 | .name(PROJECT_NAME) 16 | .description('CLI tool for Puter cloud platform') 17 | .version(version); 18 | 19 | program 20 | .command('login') 21 | .description('Login to Puter account') 22 | .option('-s, --save', 'Save authentication token in .env file', '') 23 | .action(login); 24 | 25 | program 26 | .command('logout') 27 | .description('Logout from Puter account') 28 | .action(logout); 29 | 30 | program 31 | .command('init') 32 | .description('Initialize a new Puter app') 33 | .action(init); 34 | 35 | program 36 | .command('shell') 37 | .description('Start interactive shell') 38 | .action(startShell); 39 | 40 | 41 | // App commands 42 | program 43 | .command('app:create') 44 | .description('Create a new Puter application') 45 | .argument('', 'Name of the application') 46 | .argument('[remoteDir]', 'Remote directory path') 47 | .option('-d, --description [description]', 'Application description', '') 48 | .option('-u, --url ', 'Application URL', 'https://dev-center.puter.com/coming-soon.html') 49 | .action(async (name, remoteDir, options) => { 50 | try { 51 | await createApp({ 52 | name, 53 | directory: remoteDir || '', 54 | description: options.description || '', 55 | url: options.url 56 | }); 57 | } catch (error) { 58 | console.error(chalk.red(error.message)); 59 | } 60 | process.exit(0); 61 | }); 62 | 63 | if (process.argv.length === 2) { 64 | startShell(); 65 | } else { 66 | program.parse(process.argv); 67 | } 68 | } 69 | 70 | main().catch((err) => { 71 | console.error(err); 72 | process.exit(1); 73 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "puter-cli", 3 | "version": "1.8.0", 4 | "description": "Command line interface for Puter cloud platform", 5 | "main": "index.js", 6 | "bin": { 7 | "puter": "./bin/index.js" 8 | }, 9 | "preferGlobal": true, 10 | "type": "module", 11 | "scripts": { 12 | "start": "node bin/index.js", 13 | "test": "TZ=UTC vitest run tests/*", 14 | "test:watch": "TZ=UTC vitest --watch tests/*", 15 | "version": "auto-changelog -p && git add CHANGELOG.md", 16 | "coverage": "TZ=UTC vitest run --coverage" 17 | }, 18 | "engines": { 19 | "node": ">=18.0.0" 20 | }, 21 | "keywords": [ 22 | "puter", 23 | "cli", 24 | "cloud" 25 | ], 26 | "author": "Ibrahim.H", 27 | "license": "MIT", 28 | "dependencies": { 29 | "@heyputer/putility": "^1.0.2", 30 | "chalk": "^5.3.0", 31 | "cli-table3": "^0.6.5", 32 | "commander": "^13.0.0", 33 | "conf": "^12.0.0", 34 | "cross-spawn": "^7.0.3", 35 | "dotenv": "^16.4.7", 36 | "glob": "^11.0.0", 37 | "inquirer": "^9.2.12", 38 | "minimatch": "^10.0.1", 39 | "node-fetch": "^3.3.2", 40 | "ora": "^8.0.1", 41 | "uuid": "^11.0.5", 42 | "yargs-parser": "^21.1.1" 43 | }, 44 | "devDependencies": { 45 | "@vitest/coverage-v8": "2.1.8", 46 | "auto-changelog": "^2.5.0", 47 | "vitest": "^2.1.8" 48 | }, 49 | "repository": { 50 | "type": "git", 51 | "url": "git+https://github.com/HeyPuter/puter-cli.git" 52 | }, 53 | "bugs": { 54 | "url": "https://github.com/HeyPuter/puter-cli/issues" 55 | }, 56 | "homepage": "https://github.com/HeyPuter/puter-cli" 57 | } 58 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HeyPuter/puter-cli/3cbcd2f95521481e1a487f0e59bbbc35e86048de/screenshot.png -------------------------------------------------------------------------------- /src/commands/apps.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import chalk from 'chalk'; 3 | import fetch from 'node-fetch'; 4 | import Table from 'cli-table3'; 5 | import { displayNonNullValues, formatDate } from '../utils.js'; 6 | import { API_BASE, getHeaders, getDefaultHomePage, isValidAppName, resolvePath } from '../commons.js'; 7 | import { createSubdomain, getSubdomains } from './subdomains.js'; 8 | import { deleteSite } from './sites.js'; 9 | import { copyFile, createFile, listRemoteFiles, pathExists, removeFileOrDirectory } from './files.js'; 10 | import { getCurrentDirectory } from './auth.js'; 11 | import crypto from '../crypto.js'; 12 | 13 | /** 14 | * List all apps 15 | * 16 | * @param {object} options 17 | * ```json 18 | * { 19 | * statsPeriod: [all (default), today, yesterday, 7d, 30d, this_month, last_month, this_year, last_year, month_to_date, year_to_date, last_12_months], 20 | * iconSize: [16, 32, 64, 128, 256, 512] 21 | * } 22 | * ``` 23 | */ 24 | export async function listApps({ statsPeriod = 'all', iconSize = 64 } = {}) { 25 | console.log(chalk.green(`Listing of apps during period "${chalk.cyan(statsPeriod)}" (try also: today, yesterday, 7d, 30d, this_month, last_month):\n`)); 26 | try { 27 | const response = await fetch(`${API_BASE}/drivers/call`, { 28 | method: 'POST', 29 | headers: getHeaders(), 30 | body: JSON.stringify({ 31 | interface: "puter-apps", 32 | method: "select", 33 | args: { 34 | params: { icon_size: iconSize }, 35 | predicate: ["user-can-edit"], 36 | stats_period: statsPeriod, 37 | } 38 | }) 39 | }); 40 | const data = await response.json(); 41 | if (data && data['result']) { 42 | // Create a new table instance 43 | const table = new Table({ 44 | head: [ 45 | chalk.cyan('#'), 46 | chalk.cyan('Title'), 47 | chalk.cyan('Name'), 48 | chalk.cyan('Created'), 49 | chalk.cyan('Subdomain'), 50 | // chalk.cyan('Description'), 51 | chalk.cyan('#Open'), 52 | chalk.cyan('#User') 53 | ], 54 | colWidths: [5, 20, 30, 25, 35, 8, 8], 55 | wordWrap: false 56 | }); 57 | 58 | // Populate the table with app data 59 | let i = 0; 60 | for (const app of data['result']) { 61 | table.push([ 62 | i++, 63 | app['title'], 64 | app['name'], 65 | formatDate(app['created_at']), 66 | app['index_url']?app['index_url'].split('.')[0].split('//')[1]:'', 67 | // app['description'].slice(0, 10) || 'N/A', 68 | app['stats']['open_count'], 69 | app['stats']['user_count'] 70 | ]); 71 | } 72 | 73 | // Display the table 74 | console.log(table.toString()); 75 | console.log(chalk.green(`You have in total: ${chalk.cyan(data['result'].length)} application(s).`)); 76 | } else { 77 | console.error(chalk.red('Unable to list your apps. Please check your credentials.')); 78 | } 79 | } catch (error) { 80 | console.error(chalk.red(`Failed to list apps. Error: ${error.message}`)); 81 | } 82 | } 83 | 84 | /** 85 | * Get app informations 86 | * 87 | * @param {Array} List of options (only "name" is supported at the moment) 88 | * @example: 89 | * ```json 90 | * const data = await appInfo("app name"); 91 | * ``` 92 | */ 93 | export async function appInfo(args = []) { 94 | if (!args || args.length == 0){ 95 | console.log(chalk.red('Usage: app ')); 96 | return; 97 | } 98 | const appName = args[0].trim() 99 | console.log(chalk.green(`Looking for "${chalk.dim(appName)}" app informations:\n`)); 100 | try { 101 | const response = await fetch(`${API_BASE}/drivers/call`, { 102 | method: 'POST', 103 | headers: getHeaders(), 104 | body: JSON.stringify({ 105 | interface: "puter-apps", 106 | method: "read", 107 | args: { 108 | id: { 109 | name: appName 110 | } 111 | } 112 | }) 113 | }); 114 | const data = await response.json(); 115 | if (data && data['result']) { 116 | // Display the informations 117 | displayNonNullValues(data['result']); 118 | } else { 119 | console.error(chalk.red('Could not find this app.')); 120 | } 121 | } catch (error) { 122 | console.error(chalk.red(`Failed to get app info. Error: ${error.message}`)); 123 | } 124 | } 125 | 126 | /** 127 | * Create a new web application 128 | * @param {string} name The name of the App 129 | * @param {string} directory Optional directory path 130 | * @param {string} description A description of the App 131 | * @param {string} url A default coming-soon URL 132 | * @returns {Promise} Output JSON data 133 | */ 134 | export async function createApp(args) { 135 | const name = args.name; // App name (required) 136 | console.log(args); 137 | if (!isValidAppName(name)) { 138 | throw new Error('Invalid application name'); 139 | } 140 | // Use the default home page if the root directory if none specified 141 | const localDir = args.directory ? resolvePath(getCurrentDirectory(), args.directory) : ''; 142 | // Optional description 143 | const description = args.description || ''; 144 | const url = args.url || ''; 145 | 146 | console.log(chalk.green(`Creating app "${name}"...`)); 147 | console.log(chalk.dim(`Directory: ${localDir || '[default]'}`)); 148 | console.log(chalk.dim(`Description: ${description}`)); 149 | console.log(chalk.dim(`URL: ${url}`)); 150 | 151 | try { 152 | // Step 1: Create the app 153 | const createAppResponse = await fetch(`${API_BASE}/drivers/call`, { 154 | method: 'POST', 155 | headers: getHeaders(), 156 | body: JSON.stringify({ 157 | interface: "puter-apps", 158 | method: "create", 159 | args: { 160 | object: { 161 | name: name, 162 | index_url: url, 163 | title: name, 164 | description: description, 165 | maximize_on_start: false, 166 | background: false, 167 | metadata: { 168 | window_resizable: true 169 | } 170 | }, 171 | options: { 172 | dedupe_name: true 173 | } 174 | } 175 | }) 176 | }); 177 | const createAppData = await createAppResponse.json(); 178 | if (!createAppData || !createAppData.success) { 179 | console.error(chalk.red(`Failed to create app "${name}"`)); 180 | return; 181 | } 182 | const appUid = createAppData.result.uid; 183 | const appName = createAppData.result.name; 184 | const username = createAppData.result.owner.username; 185 | console.log(chalk.green(`App "${chalk.dim(name)}" created successfully!`)); 186 | console.log(chalk.cyan(`AppName: ${chalk.dim(appName)}\nUID: ${chalk.dim(appUid)}\nUsername: ${chalk.dim(username)}`)); 187 | 188 | // Step 2: Create a directory for the app 189 | const uid = crypto.randomUUID(); 190 | const appDir = `/${username}/AppData/${appUid}`; 191 | console.log(chalk.green(`Creating directory...\nPath: ${chalk.dim(appDir)}\nApp: ${chalk.dim(name)}\nUID: ${chalk.dim(uid)}\n`)); 192 | const createDirResponse = await fetch(`${API_BASE}/mkdir`, { 193 | method: 'POST', 194 | headers: getHeaders(), 195 | body: JSON.stringify({ 196 | parent: appDir, 197 | path: `app-${uid}`, 198 | overwrite: true, 199 | dedupe_name: false, 200 | create_missing_parents: true 201 | }) 202 | }); 203 | const createDirData = await createDirResponse.json(); 204 | if (!createDirData || !createDirData.uid) { 205 | console.error(chalk.red(`Failed to create directory for app "${name}"`)); 206 | return; 207 | } 208 | const dirUid = createDirData.uid; 209 | console.log(chalk.green(`Directory created successfully!`)); 210 | console.log(chalk.cyan(`Directory UID: ${chalk.dim(dirUid)}`)); 211 | 212 | // Step 3: Create a subdomain for the app 213 | const subdomainName = `${name}-${uid.split('-')[0]}`; 214 | const remoteDir = `${appDir}/${createDirData.name}`; 215 | console.log(chalk.green(`Linking to subdomain...\nSubdomain: "${chalk.dim(subdomainName)}"\nPath: ${chalk.dim(remoteDir)}\n`)); 216 | const subdomainResult = await createSubdomain(subdomainName, remoteDir); 217 | if (!subdomainResult) { 218 | console.error(chalk.red(`Failed to create subdomain: "${subdomainName}"`)); 219 | return; 220 | } 221 | console.log(chalk.green(`Subdomain created successfully!`)); 222 | console.log(chalk.cyan(`Subdomain: ${chalk.dim(subdomainName)}`)); 223 | 224 | // Step 4: Create a home page 225 | if (localDir.length > 0){ 226 | // List files in the current "localDir" then copy them to the "remoteDir" 227 | const files = await listRemoteFiles(localDir); 228 | if (Array.isArray(files) && files.length > 0) { 229 | console.log(chalk.cyan(`Copying ${chalk.dim(files.length)} files from: ${chalk.dim(localDir)}`)); 230 | console.log(chalk.cyan(`To destination: ${chalk.dim(remoteDir)}`)); 231 | for (const file of files) { 232 | const fileSource = path.join(localDir, file.name); 233 | await copyFile([fileSource, remoteDir]); 234 | } 235 | } else { 236 | console.log(chalk.yellow("We could not find any file in the specified directory!")); 237 | } 238 | } else { 239 | const homePageResult = await createFile([path.join(remoteDir, 'index.html'), getDefaultHomePage(appName)]); 240 | if (!homePageResult){ 241 | console.log(chalk.yellow("We could not create the home page file!")); 242 | } 243 | } 244 | 245 | // Step 5: Update the app's index_url to point to the subdomain 246 | console.log(chalk.green(`Set "${chalk.dim(subdomainName)}" as a subdomain for app: "${chalk.dim(appName)}"...\n`)); 247 | const updateAppResponse = await fetch(`${API_BASE}/drivers/call`, { 248 | method: 'POST', 249 | headers: getHeaders(), 250 | body: JSON.stringify({ 251 | interface: "puter-apps", 252 | method: "update", 253 | args: { 254 | id: { name: appName }, 255 | object: { 256 | index_url: `https://${subdomainName}.puter.site`, 257 | title: name 258 | } 259 | } 260 | }) 261 | }); 262 | const updateAppData = await updateAppResponse.json(); 263 | if (!updateAppData || !updateAppData.success) { 264 | console.error(chalk.red(`Failed to update app "${name}" with new subdomain`)); 265 | return; 266 | } 267 | console.log(chalk.green(`App deployed successfully at:`)); 268 | console.log(chalk.cyanBright(`https://${subdomainName}.puter.site`)); 269 | } catch (error) { 270 | console.error(chalk.red(`Failed to create app "${name}".\nError: ${error.message}`)); 271 | } 272 | } 273 | 274 | /** 275 | * Update an application from the directory 276 | * @param {string} name The name of the App 277 | * @param {string} remote_dir The remote directory 278 | */ 279 | export async function updateApp(args = []) { 280 | if (args.length < 1) { 281 | console.log(chalk.red('Usage: app:update []')); 282 | console.log(chalk.yellow('Example: app:create myapp')); 283 | console.log(chalk.yellow('Example: app:create myapp ./myapp')); 284 | return; 285 | } 286 | const name = args[0]; // App name (required) 287 | // Fix: Properly handle absolute paths by checking if the path starts with '/' 288 | let remoteDir; 289 | if (args[1] && args[1].startsWith('/')) { 290 | remoteDir = args[1]; // Use the absolute path as-is 291 | } else { 292 | remoteDir = resolvePath(getCurrentDirectory(), args[1] || '.'); 293 | } 294 | 295 | const remoteDirExists = await pathExists(remoteDir); 296 | 297 | if (!remoteDirExists){ 298 | console.log(chalk.red(`Cannot find directory: ${chalk.dim(remoteDir)}...\n`)); 299 | return; 300 | } 301 | 302 | console.log(chalk.green(`Updating app: "${chalk.dim(name)}" from directory: ${chalk.dim(remoteDir)}\n`)); 303 | try { 304 | // Step 1: Get the app info 305 | const appResponse = await fetch(`${API_BASE}/drivers/call`, { 306 | method: 'POST', 307 | headers: getHeaders(), 308 | body: JSON.stringify({ 309 | interface: "puter-apps", 310 | method: "read", 311 | args: { 312 | id: { name } 313 | } 314 | }) 315 | }); 316 | const data = await appResponse.json(); 317 | if (!data || !data.success) { 318 | console.error(chalk.red(`Failed to find app: "${name}"`)); 319 | return; 320 | } 321 | const appUid = data.result.uid; 322 | const appName = data.result.name; 323 | const username = data.result.owner.username; 324 | const indexUrl = data.result.index_url; 325 | const appDir = `/${username}/AppData/${appUid}`; 326 | console.log(chalk.cyan(`AppName: ${chalk.dim(appName)}\nUID: ${chalk.dim(appUid)}\nUsername: ${chalk.dim(username)}`)); 327 | 328 | // Step 2: Find the path from subdomain 329 | const subdomains = await getSubdomains(); 330 | const appSubdomain = subdomains.result.find(sd => sd.root_dir?.dirname?.endsWith(appUid)); 331 | if (!appSubdomain){ 332 | console.error(chalk.red(`Sorry! We could not find the subdomain for ${chalk.cyan(name)} application.`)); 333 | return; 334 | } 335 | const subdomainDir = appSubdomain['root_dir']['path']; 336 | if (!subdomainDir){ 337 | console.error(chalk.red(`Sorry! We could not find the path for ${chalk.cyan(name)} application.`)); 338 | return; 339 | } 340 | 341 | // Step 3: List files in the current "remoteDir" then copy them to the "subdomainDir" 342 | const files = await listRemoteFiles(remoteDir); 343 | if (Array.isArray(files) && files.length > 0) { 344 | console.log(chalk.cyan(`Copying ${chalk.dim(files.length)} files from: ${chalk.dim(remoteDir)}`)); 345 | console.log(chalk.cyan(`To destination: ${chalk.dim(subdomainDir)}`)); 346 | for (const file of files) { 347 | const fileSource = path.join(remoteDir, file.name); 348 | const fileDest = path.join(subdomainDir, file.name); 349 | if ((await pathExists(fileDest))){ 350 | await removeFileOrDirectory([fileDest, '-f']); 351 | } 352 | await copyFile([fileSource, subdomainDir]); 353 | } 354 | } else { 355 | console.log(chalk.red("We could not find any file in the specified directory!")); 356 | } 357 | 358 | console.log(chalk.green(`App updated successfully at:`)); 359 | console.log(chalk.dim(indexUrl)); 360 | } catch (error) { 361 | console.error(chalk.red(`Failed to update app "${name}".\nError: ${error.message}`)); 362 | } 363 | } 364 | 365 | /** 366 | * Delete an app by its name 367 | * @param {string} name The name of the app to delete 368 | * @returns a boolean success value 369 | */ 370 | export async function deleteApp(name) { 371 | if (!name || name.length == 0){ 372 | console.log(chalk.red('Usage: app:delete ')); 373 | return false; 374 | } 375 | console.log(chalk.green(`Checking app "${name}"...\n`)); 376 | try { 377 | // Step 1: Read app details 378 | const readResponse = await fetch(`${API_BASE}/drivers/call`, { 379 | method: 'POST', 380 | headers: getHeaders(), 381 | body: JSON.stringify({ 382 | interface: "puter-apps", 383 | method: "read", 384 | args: { 385 | id: { name } 386 | } 387 | }) 388 | }); 389 | 390 | const readData = await readResponse.json(); 391 | 392 | if (!readData.success || !readData.result) { 393 | console.log(chalk.red(`App "${chalk.bold(name)}" not found.`)); 394 | return false; 395 | } 396 | 397 | // Show app details and confirm deletion 398 | console.log(chalk.cyan('\nApp Details:')); 399 | console.log(chalk.dim('----------------------------------------')); 400 | console.log(chalk.dim(`Name: ${chalk.cyan(readData.result.name)}`)); 401 | console.log(chalk.dim(`Title: ${chalk.cyan(readData.result.title)}`)); 402 | console.log(chalk.dim(`Created: ${chalk.cyan(formatDate(readData.result.created_at))}`)); 403 | console.log(chalk.dim(`URL: ${readData.result.index_url}`)); 404 | console.log(chalk.dim('----------------------------------------')); 405 | 406 | // Step 2: Delete the app 407 | console.log(chalk.green(`Deleting app "${chalk.red(name)}"...`)); 408 | const deleteResponse = await fetch(`${API_BASE}/drivers/call`, { 409 | method: 'POST', 410 | headers: getHeaders(), 411 | body: JSON.stringify({ 412 | interface: "puter-apps", 413 | method: "delete", 414 | args: { 415 | id: { name } 416 | } 417 | }) 418 | });path 419 | 420 | const deleteData = await deleteResponse.json(); 421 | if (!deleteData.success) { 422 | console.error(chalk.red(`Failed to delete app "${name}".\nP.S. Make sure to provide the 'name' attribute not the 'title'.`)); 423 | return false; 424 | } 425 | 426 | // Lookup subdomainUID then delete it 427 | const subdomains = await getSubdomains(); 428 | const appSubdomain = subdomains.result.find(sd => sd.root_dir?.dirname?.endsWith(readData.result.uid)); 429 | const subdomainDeleted = await deleteSite([appSubdomain.uid]); 430 | if (subdomainDeleted){ 431 | console.log(chalk.green(`Subdomain: ${chalk.dim(appSubdomain.uid)} deleted.`)); 432 | } 433 | 434 | console.log(chalk.green(`App "${chalk.dim(name)}" deleted successfully!`)); 435 | } catch (error) { 436 | console.error(chalk.red(`Failed to delete app "${name}".\nError: ${error.message}`)); 437 | return false; 438 | } 439 | return true; 440 | } 441 | -------------------------------------------------------------------------------- /src/commands/auth.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import inquirer from 'inquirer'; 3 | import chalk from 'chalk'; 4 | import Conf from 'conf'; 5 | import ora from 'ora'; 6 | import fetch from 'node-fetch'; 7 | import { PROJECT_NAME, API_BASE, getHeaders, BASE_URL } from '../commons.js' 8 | const config = new Conf({ projectName: PROJECT_NAME }); 9 | 10 | /** 11 | * Login user 12 | * @returns void 13 | */ 14 | export async function login(args = {}) { 15 | const answers = await inquirer.prompt([ 16 | { 17 | type: 'input', 18 | name: 'username', 19 | message: 'Username:', 20 | validate: input => input.length >= 1 || 'Username is required' 21 | }, 22 | { 23 | type: 'password', 24 | name: 'password', 25 | message: 'Password:', 26 | mask: '*', 27 | validate: input => input.length >= 1 || 'Password is required' 28 | } 29 | ]); 30 | 31 | let spinner; 32 | try { 33 | spinner = ora('Logging in to Puter...').start(); 34 | 35 | const response = await fetch(`${BASE_URL}/login`, { 36 | method: 'POST', 37 | headers: getHeaders(), 38 | body: JSON.stringify({ 39 | username: answers.username, 40 | password: answers.password 41 | }) 42 | }); 43 | 44 | let data = await response.json(); 45 | 46 | while ( data.proceed && data.next_step ) { 47 | if ( data.next_step === 'otp') { 48 | spinner.succeed(chalk.green('2FA is enabled')); 49 | const answers = await inquirer.prompt([ 50 | { 51 | type: 'input', 52 | name: 'otp', 53 | message: 'Authenticator Code:', 54 | validate: input => input.length === 6 || 'OTP must be 6 digits' 55 | } 56 | ]); 57 | spinner = ora('Logging in to Puter...').start(); 58 | const response = await fetch(`${BASE_URL}/login/otp`, { 59 | method: 'POST', 60 | headers: getHeaders(), 61 | body: JSON.stringify({ 62 | token: data.otp_jwt_token, 63 | code: answers.otp, 64 | }), 65 | }); 66 | data = await response.json(); 67 | continue; 68 | } 69 | 70 | if ( data.next_step === 'complete' ) break; 71 | 72 | spinner.fail(chalk.red(`Unrecognized login step "${data.next_step}"; you might need to update puter-cli.`)); 73 | return; 74 | } 75 | 76 | if (data.proceed && data.token) { 77 | config.set('auth_token', data.token); 78 | config.set('username', answers.username); 79 | config.set('cwd', `/${answers.username}`); 80 | if (spinner){ 81 | spinner.succeed(chalk.green('Successfully logged in to Puter!')); 82 | } 83 | console.log(chalk.dim(`Token: ${data.token.slice(0, 5)}...${data.token.slice(-5)}`)); 84 | // Save token 85 | if (args.save){ 86 | const localEnvFile = '.env'; 87 | try { 88 | // Check if the file exists, if so then delete it before writing. 89 | if (fs.existsSync(localEnvFile)) { 90 | console.log(chalk.yellow(`File "${localEnvFile}" already exists... Adding token.`)); 91 | fs.appendFileSync(localEnvFile, `\nPUTER_API_KEY="${data.token}"`, 'utf8'); 92 | } else { 93 | console.log(chalk.cyan(`Saving token to ${chalk.green(localEnvFile)} file.`)); 94 | fs.writeFileSync(localEnvFile, `PUTER_API_KEY="${data.token}"`, 'utf8'); 95 | } 96 | } catch (error) { 97 | console.error(chalk.red(`Cannot save token to .env file. Error: ${error.message}`)); 98 | console.log(chalk.cyan(`PUTER_API_KEY="${data.token}"`)); 99 | } 100 | } 101 | } else { 102 | spinner.fail(chalk.red('Login failed. Please check your credentials.')); 103 | } 104 | } catch (error) { 105 | if (spinner) { 106 | spinner.fail(chalk.red('Failed to login')); 107 | } else { 108 | console.error(chalk.red(`Failed to login: ${error.message}`)); 109 | } 110 | } 111 | } 112 | 113 | /** 114 | * Logout user 115 | * @returns void 116 | */ 117 | export async function logout() { 118 | 119 | let spinner; 120 | try { 121 | spinner = ora('Logging out from Puter...').start(); 122 | const token = config.get('auth_token'); 123 | if (!token) { 124 | spinner.info(chalk.yellow('Already logged out')); 125 | return; 126 | } 127 | 128 | config.clear(); // Remove all stored data 129 | spinner.succeed(chalk.green('Successfully logged out from Puter!')); 130 | } catch (error) { 131 | if (spinner){ 132 | spinner.fail(chalk.red('Failed to logout')); 133 | } 134 | console.error(chalk.red(`Error: ${error.message}`)); 135 | } 136 | } 137 | 138 | export async function getUserInfo() { 139 | console.log(chalk.green('Getting user info...\n')); 140 | try { 141 | const response = await fetch(`${API_BASE}/whoami`, { 142 | method: 'GET', 143 | headers: getHeaders() 144 | }); 145 | const data = await response.json(); 146 | if (data) { 147 | console.log(chalk.cyan('User Information:')); 148 | console.log(chalk.dim('----------------------------------------')); 149 | console.log(chalk.cyan(`Username: `) + chalk.white(data.username)); 150 | console.log(chalk.cyan(`UUID: `) + chalk.white(data.uuid)); 151 | console.log(chalk.cyan(`Email: `) + chalk.white(data.email)); 152 | console.log(chalk.cyan(`Email Confirmed: `) + chalk.white(data.email_confirmed ? 'Yes' : 'No')); 153 | console.log(chalk.cyan(`Temporary Account: `) + chalk.white(data.is_temp ? 'Yes' : 'No')); 154 | console.log(chalk.cyan(`Account Age: `) + chalk.white(data.human_readable_age)); 155 | console.log(chalk.dim('----------------------------------------')); 156 | console.log(chalk.cyan('Feature Flags:')); 157 | for (const [flag, enabled] of Object.entries(data.feature_flags)) { 158 | console.log(chalk.cyan(` - ${flag}: `) + chalk.white(enabled ? 'Enabled' : 'Disabled')); 159 | } 160 | console.log(chalk.dim('----------------------------------------')); 161 | console.log(chalk.green('Done.')); 162 | } else { 163 | console.error(chalk.red('Unable to get your info. Please check your credentials.')); 164 | } 165 | } catch (error) { 166 | console.error(chalk.red(`Failed to get user info.\nError: ${error.message}`)); 167 | } 168 | } 169 | export function isAuthenticated() { 170 | return !!config.get('auth_token'); 171 | } 172 | 173 | export function getAuthToken() { 174 | return config.get('auth_token'); 175 | } 176 | 177 | export function getCurrentUserName() { 178 | return config.get('username'); 179 | } 180 | 181 | export function getCurrentDirectory() { 182 | return config.get('cwd'); 183 | } 184 | 185 | /** 186 | * Fetch usage information 187 | */ 188 | export async function getUsageInfo() { 189 | console.log(chalk.green('Fetching usage information...\n')); 190 | try { 191 | const response = await fetch(`${API_BASE}/drivers/usage`, { 192 | method: 'GET', 193 | headers: getHeaders() 194 | }); 195 | 196 | const data = await response.json(); 197 | if (data) { 198 | console.log(chalk.cyan('Usage Information:')); 199 | console.log(chalk.dim('========================================')); 200 | 201 | // Display user usage in a table 202 | if (data.user && data.user.length > 0) { 203 | console.log(chalk.cyan('User Usage:')); 204 | console.log(chalk.dim('----------------------------------------')); 205 | console.log( 206 | chalk.bold('Service'.padEnd(30)) + 207 | chalk.bold('Implementation'.padEnd(20)) + 208 | chalk.bold('Month'.padEnd(10)) + 209 | chalk.bold('Usage'.padEnd(10)) + 210 | chalk.bold('Limit'.padEnd(10)) + 211 | chalk.bold('Rate Limit') 212 | ); 213 | console.log(chalk.dim('----------------------------------------')); 214 | data.user.forEach(usage => { 215 | const service = `${usage.service['driver.interface']}.${usage.service['driver.method']}`; 216 | const implementation = usage.service['driver.implementation']; 217 | const month = `${usage.month}/${usage.year}`; 218 | const monthlyUsage = usage.monthly_usage?.toString(); 219 | const monthlyLimit = usage.monthly_limit ? usage.monthly_limit.toString() : 'No Limit'; 220 | const rateLimit = usage.policy ? `${usage.policy['rate-limit'].max} req/${usage.policy['rate-limit'].period / 1000}s` : 'N/A'; 221 | 222 | console.log( 223 | service.padEnd(30) + 224 | implementation.padEnd(20) + 225 | month.padEnd(10) + 226 | monthlyUsage.padEnd(10) + 227 | monthlyLimit.padEnd(10) + 228 | rateLimit 229 | ); 230 | }); 231 | console.log(chalk.dim('----------------------------------------')); 232 | } 233 | 234 | // Display app usage in a table (if available) 235 | if (data.apps && Object.keys(data.apps).length > 0) { 236 | console.log(chalk.cyan('\nApp Usage:')); 237 | console.log(chalk.dim('----------------------------------------')); 238 | console.log( 239 | chalk.bold('App'.padEnd(30)) + 240 | chalk.bold('Usage'.padEnd(10)) + 241 | chalk.bold('Limit'.padEnd(10)) 242 | ); 243 | console.log(chalk.dim('----------------------------------------')); 244 | for (const [app, usage] of Object.entries(data.apps)) { 245 | console.log( 246 | app.padEnd(30) + 247 | usage.used.toString().padEnd(10) + 248 | usage.available.toString().padEnd(10) 249 | ); 250 | } 251 | console.log(chalk.dim('----------------------------------------')); 252 | } 253 | 254 | // Display general usages in a table (if available) 255 | if (data.usages && data.usages.length > 0) { 256 | console.log(chalk.cyan('\nGeneral Usages:')); 257 | console.log(chalk.dim('----------------------------------------')); 258 | console.log( 259 | chalk.bold('Name'.padEnd(30)) + 260 | chalk.bold('Used'.padEnd(10)) + 261 | chalk.bold('Available'.padEnd(10)) + 262 | chalk.bold('Refill') 263 | ); 264 | console.log(chalk.dim('----------------------------------------')); 265 | data.usages.forEach(usage => { 266 | console.log( 267 | usage.name.padEnd(30) + 268 | usage.used.toString().padEnd(10) + 269 | usage.available.toString().padEnd(10) + 270 | usage.refill 271 | ); 272 | }); 273 | console.log(chalk.dim('----------------------------------------')); 274 | } 275 | 276 | console.log(chalk.dim('========================================')); 277 | console.log(chalk.green('Done.')); 278 | } else { 279 | console.error(chalk.red('Unable to fetch usage information.')); 280 | } 281 | } catch (error) { 282 | console.error(chalk.red(`Failed to fetch usage information.\nError: ${error.message}`)); 283 | } 284 | } -------------------------------------------------------------------------------- /src/commands/files.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import os from 'node:os'; 3 | import { execSync } from 'node:child_process'; 4 | import { glob } from 'glob'; 5 | import path from 'path'; 6 | import { minimatch } from 'minimatch'; 7 | import chalk from 'chalk'; 8 | import Conf from 'conf'; 9 | import fetch from 'node-fetch'; 10 | import { API_BASE, BASE_URL, PROJECT_NAME, getHeaders, showDiskSpaceUsage, resolvePath } from '../commons.js'; 11 | import { formatDateTime, formatSize, getSystemEditor } from '../utils.js'; 12 | import inquirer from 'inquirer'; 13 | import { getAuthToken, getCurrentDirectory, getCurrentUserName } from './auth.js'; 14 | import { updatePrompt } from './shell.js'; 15 | import crypto from '../crypto.js'; 16 | 17 | 18 | const config = new Conf({ projectName: PROJECT_NAME }); 19 | 20 | 21 | /** 22 | * List files in given path 23 | * @param {string} path Path to the file or directory 24 | * @returns List of files found 25 | */ 26 | export async function listRemoteFiles(path) { 27 | const response = await fetch(`${API_BASE}/readdir`, { 28 | method: 'POST', 29 | headers: getHeaders(), 30 | body: JSON.stringify({ path }) 31 | }); 32 | return await response.json(); 33 | } 34 | 35 | /** 36 | * List files in the current working directory. 37 | * @param {string} args Default current working directory 38 | */ 39 | export async function listFiles(args = []) { 40 | const names = args.length > 0 ? args : ['.']; 41 | for (let path of names) 42 | try { 43 | if (!path.startsWith('/')){ 44 | path = resolvePath(getCurrentDirectory(), path); 45 | } 46 | if (!(await pathExists(path))){ 47 | console.log(chalk.yellow(`Directory ${chalk.red(path)} doesn't exists!`)); 48 | continue; 49 | } 50 | console.log(chalk.green(`Listing files in ${chalk.dim(path)}:\n`)); 51 | const files = await listRemoteFiles(path); 52 | if (Array.isArray(files) && files.length > 0) { 53 | console.log(chalk.cyan(`Type Name Size Modified UID`)); 54 | console.log(chalk.dim('----------------------------------------------------------------------------------')); 55 | files.forEach(file => { 56 | const type = file.is_dir ? 'd' : '-'; 57 | const write = file.writable ? 'w' : '-'; 58 | const name = file.name.padEnd(20); 59 | const size = file.is_dir ? '0' : formatSize(file.size); 60 | const modified = formatDateTime(file.modified); 61 | const uid = file.uid?.split('-'); 62 | console.log(`${type}${write} ${name} ${size.padEnd(8)} ${modified} ${uid[0]}-...-${uid.slice(-1)}`); 63 | }); 64 | console.log(chalk.green(`There are ${files.length} object(s).`)); 65 | } else { 66 | console.log(chalk.red('No files or directories found.')); 67 | } 68 | } catch (error) { 69 | console.log(chalk.red('Failed to list files.')); 70 | console.error(chalk.red(`Error: ${error.message}`)); 71 | } 72 | } 73 | 74 | /** 75 | * Create a folder in the current working directory. 76 | * @param {Array} args Options 77 | * @returns void 78 | */ 79 | export async function makeDirectory(args = []) { 80 | if (args.length < 1) { 81 | console.log(chalk.red('Usage: mkdir ')); 82 | return; 83 | } 84 | 85 | const directoryName = args[0]; 86 | console.log(chalk.green(`Creating directory "${directoryName}" in "${getCurrentDirectory()}"...\n`)); 87 | 88 | try { 89 | const response = await fetch(`${API_BASE}/mkdir`, { 90 | method: 'POST', 91 | headers: getHeaders(), 92 | body: JSON.stringify({ 93 | parent: getCurrentDirectory(), 94 | path: directoryName, 95 | overwrite: false, 96 | dedupe_name: true, 97 | create_missing_parents: false 98 | }) 99 | }); 100 | 101 | const data = await response.json(); 102 | if (data && data.id) { 103 | console.log(chalk.green(`Directory "${directoryName}" created successfully!`)); 104 | console.log(chalk.dim(`Path: ${data.path}`)); 105 | console.log(chalk.dim(`UID: ${data.uid}`)); 106 | } else { 107 | console.log(chalk.red('Failed to create directory. Please check your input.')); 108 | } 109 | } catch (error) { 110 | console.log(chalk.red('Failed to create directory.')); 111 | console.error(chalk.red(`Error: ${error.message}`)); 112 | } 113 | } 114 | 115 | /** 116 | * Rename a file or directory 117 | * @param {Array} args Options 118 | * @returns void 119 | */ 120 | export async function renameFileOrDirectory(args = []) { 121 | if (args.length < 2) { 122 | console.log(chalk.red('Usage: mv ')); 123 | return; 124 | } 125 | 126 | const sourcePath = args[0].startsWith('/') ? args[0] : resolvePath(getCurrentDirectory(), args[0]); 127 | const destPath = args[1].startsWith('/') ? args[1] : resolvePath(getCurrentDirectory(), args[1]); 128 | 129 | console.log(chalk.green(`Moving "${sourcePath}" to "${destPath}"...\n`)); 130 | 131 | try { 132 | // Step 1: Get the source file/directory info 133 | const statResponse = await fetch(`${API_BASE}/stat`, { 134 | method: 'POST', 135 | headers: getHeaders(), 136 | body: JSON.stringify({ path: sourcePath }) 137 | }); 138 | 139 | const statData = await statResponse.json(); 140 | if (!statData || !statData.uid) { 141 | console.log(chalk.red(`Could not find source "${sourcePath}".`)); 142 | return; 143 | } 144 | 145 | const sourceUid = statData.uid; 146 | const sourceName = statData.name; 147 | 148 | // Step 2: Check if destination is an existing directory 149 | const destStatResponse = await fetch(`${API_BASE}/stat`, { 150 | method: 'POST', 151 | headers: getHeaders(), 152 | body: JSON.stringify({ path: destPath }) 153 | }); 154 | 155 | const destData = await destStatResponse.json(); 156 | 157 | // Determine if this is a rename or move operation 158 | const isMove = destData && destData.is_dir; 159 | const newName = isMove ? sourceName : path.basename(destPath); 160 | const destination = isMove ? destPath : path.dirname(destPath); 161 | 162 | if (isMove) { 163 | // Move operation: use /move endpoint 164 | const moveResponse = await fetch(`${API_BASE}/move`, { 165 | method: 'POST', 166 | headers: getHeaders(), 167 | body: JSON.stringify({ 168 | source: sourceUid, 169 | destination: destination, 170 | overwrite: false, 171 | new_name: newName, 172 | create_missing_parents: false, 173 | new_metadata: {} 174 | }) 175 | }); 176 | 177 | const moveData = await moveResponse.json(); 178 | if (moveData && moveData.moved) { 179 | console.log(chalk.green(`Successfully moved "${sourcePath}" to "${moveData.moved.path}"!`)); 180 | } else { 181 | console.log(chalk.red('Failed to move item. Please check your input.')); 182 | } 183 | } else { 184 | // Rename operation: use /rename endpoint 185 | const renameResponse = await fetch(`${API_BASE}/rename`, { 186 | method: 'POST', 187 | headers: getHeaders(), 188 | body: JSON.stringify({ 189 | uid: sourceUid, 190 | new_name: newName 191 | }) 192 | }); 193 | 194 | const renameData = await renameResponse.json(); 195 | if (renameData && renameData.uid) { 196 | console.log(chalk.green(`Successfully renamed "${sourcePath}" to "${renameData.path}"!`)); 197 | } else { 198 | console.log(chalk.red('Failed to rename item. Please check your input.')); 199 | } 200 | } 201 | } catch (error) { 202 | console.log(chalk.red('Failed to move/rename item.')); 203 | console.error(chalk.red(`Error: ${error.message}`)); 204 | } 205 | } 206 | 207 | /** 208 | * Helper function to recursively find files matching the pattern 209 | * @param {Array} files List of files 210 | * @param {string} pattern The pattern to find 211 | * @param {string} basePath the base path 212 | * @returns array of matching files 213 | */ 214 | async function findMatchingFiles(files, pattern, basePath) { 215 | const matchedPaths = []; 216 | 217 | for (const file of files) { 218 | const filePath = path.join(basePath, file.name); 219 | 220 | // Check if the current file/directory matches the pattern 221 | if (minimatch(filePath, pattern, { dot: true })) { 222 | matchedPaths.push(filePath); 223 | } 224 | 225 | // If it's a directory, recursively search its contents 226 | if (file.is_dir) { 227 | const dirResponse = await fetch(`${API_BASE}/readdir`, { 228 | method: 'POST', 229 | headers: getHeaders(), 230 | body: JSON.stringify({ path: filePath }) 231 | }); 232 | 233 | if (dirResponse.ok) { 234 | const dirFiles = await dirResponse.json(); 235 | const dirMatches = await findMatchingFiles(dirFiles, pattern, filePath); 236 | matchedPaths.push(...dirMatches); 237 | } 238 | } 239 | } 240 | 241 | return matchedPaths; 242 | } 243 | 244 | /** 245 | * Find files matching the pattern in the local directory (DEPRECATED: Not used) 246 | * @param {string} localDir - Local directory path. 247 | * @param {string} pattern - File pattern (e.g., "*.html", "myapp/*"). 248 | * @returns {Array} - Array of file objects with local and relative paths. 249 | */ 250 | function findLocalMatchingFiles(localDir, pattern) { 251 | const files = []; 252 | const walkDir = (dir) => { 253 | const entries = fs.readdirSync(dir, { withFileTypes: true }); 254 | for (const entry of entries) { 255 | const fullPath = path.join(dir, entry.name); 256 | if (entry.isDirectory()) { 257 | walkDir(fullPath); // Recursively traverse directories 258 | } else if (minimatch(fullPath, path.join(localDir, pattern), { dot: true })) { 259 | files.push({ 260 | localPath: fullPath, 261 | relativePath: path.relative(localDir, fullPath) 262 | }); 263 | } 264 | } 265 | }; 266 | 267 | walkDir(localDir); 268 | return files; 269 | } 270 | 271 | /** 272 | * Move a file/directory to the Trash 273 | * @param {Array} args Options: 274 | * -f: Force delete (no confirmation) 275 | * @returns void 276 | */ 277 | export async function removeFileOrDirectory(args = []) { 278 | if (args.length < 1) { 279 | console.log(chalk.red('Usage: rm [-f]')); 280 | return; 281 | } 282 | 283 | const skipConfirmation = args.includes('-f'); // Check the flag if provided 284 | const names = skipConfirmation ? args.filter(option => option !== '-f') : args; 285 | 286 | try { 287 | // Step 1: Fetch the list of files and directories from the server 288 | const listResponse = await fetch(`${API_BASE}/readdir`, { 289 | method: 'POST', 290 | headers: getHeaders(), 291 | body: JSON.stringify({ path: getCurrentDirectory() }) 292 | }); 293 | 294 | if (!listResponse.ok) { 295 | console.error(chalk.red('Failed to list files from the server.')); 296 | return; 297 | } 298 | 299 | const files = await listResponse.json(); 300 | if (!Array.isArray(files) || files.length == 0) { 301 | console.error(chalk.red('No files or directories found on the server.')); 302 | return; 303 | } 304 | 305 | // Step 2: Find all files/directories matching the provided patterns 306 | const matchedPaths = []; 307 | for (const name of names) { 308 | if (name.startsWith('/')){ 309 | const pattern = resolvePath('/', name); 310 | matchedPaths.push(pattern); 311 | continue; 312 | } 313 | const pattern = resolvePath(getCurrentDirectory(), name); 314 | const matches = await findMatchingFiles(files, pattern, getCurrentDirectory()); 315 | matchedPaths.push(...matches); 316 | } 317 | 318 | if (matchedPaths.length === 0) { 319 | console.error(chalk.red('No files or directories found matching the pattern.')); 320 | return; 321 | } 322 | 323 | // Step 3: Prompt for confirmation (unless -f flag is provided) 324 | if (!skipConfirmation) { 325 | console.log(chalk.yellow(`The following items will be moved to Trash:`)); 326 | console.log(chalk.cyan('Hint: Execute "clean" to empty the Trash.')); 327 | matchedPaths.forEach(path => console.log(chalk.dim(`- ${path}`))); 328 | 329 | const { confirm } = await inquirer.prompt([ 330 | { 331 | type: 'confirm', 332 | name: 'confirm', 333 | message: `Are you sure you want to move these ${matchedPaths.length} item(s) to Trash?`, 334 | default: false 335 | } 336 | ]); 337 | 338 | if (!confirm) { 339 | console.log(chalk.yellow('Operation canceled.')); 340 | return; 341 | } 342 | } 343 | 344 | // Step 4: Move each matched file/directory to Trash 345 | for (const path of matchedPaths) { 346 | try { 347 | console.log(chalk.green(`Preparing to remove "${path}"...`)); 348 | 349 | // Step 4.1: Get the UID of the file/directory 350 | const statResponse = await fetch(`${API_BASE}/stat`, { 351 | method: 'POST', 352 | headers: getHeaders(), 353 | body: JSON.stringify({ path }) 354 | }); 355 | 356 | const statData = await statResponse.json(); 357 | if (!statData || !statData.uid) { 358 | console.error(chalk.red(`Could not find file or directory with path "${path}".`)); 359 | continue; 360 | } 361 | 362 | const uid = statData.uid; 363 | const originalPath = statData.path; 364 | 365 | // Step 4.2: Perform the move operation to Trash 366 | const moveResponse = await fetch(`${API_BASE}/move`, { 367 | method: 'POST', 368 | headers: getHeaders(), 369 | body: JSON.stringify({ 370 | source: uid, 371 | destination: `/${getCurrentUserName()}/Trash`, 372 | overwrite: false, 373 | new_name: uid, // Use the UID as the new name in Trash 374 | create_missing_parents: false, 375 | new_metadata: { 376 | original_name: path.split('/').pop(), 377 | original_path: originalPath, 378 | trashed_ts: Math.floor(Date.now() / 1000) // Current timestamp 379 | } 380 | }) 381 | }); 382 | 383 | const moveData = await moveResponse.json(); 384 | if (moveData && moveData.moved) { 385 | console.log(chalk.green(`Successfully moved "${path}" to Trash!`)); 386 | console.log(chalk.dim(`Moved to: ${moveData.moved.path}`)); 387 | } else { 388 | console.error(chalk.red(`Failed to move "${path}" to Trash.`)); 389 | } 390 | } catch (error) { 391 | console.error(chalk.red(`Failed to remove "${path}".`)); 392 | console.error(chalk.red(`Error: ${error.message}`)); 393 | } 394 | } 395 | } catch (error) { 396 | console.error(chalk.red('Failed to remove items.')); 397 | console.error(chalk.red(`Error: ${error.message}`)); 398 | } 399 | } 400 | 401 | /** 402 | * Delete a folder and its contents (PREVENTED BY PUTER API) 403 | * @param {string} folderPath - The path of the folder to delete (defaults to Trash). 404 | * @param {boolean} skipConfirmation - Whether to skip the confirmation prompt. 405 | */ 406 | export async function deleteFolder(folderPath, skipConfirmation = false) { 407 | console.log(chalk.green(`Preparing to delete "${folderPath}"...\n`)); 408 | 409 | try { 410 | // Step 1: Prompt for confirmation (unless skipConfirmation is true) 411 | if (!skipConfirmation) { 412 | const { confirm } = await inquirer.prompt([ 413 | { 414 | type: 'confirm', 415 | name: 'confirm', 416 | message: `Are you sure you want to delete all contents of "${folderPath}"?`, 417 | default: false 418 | } 419 | ]); 420 | 421 | if (!confirm) { 422 | console.log(chalk.yellow('Operation canceled.')); 423 | return; 424 | } 425 | } 426 | 427 | // Step 2: Perform the delete operation 428 | const deleteResponse = await fetch(`${API_BASE}/delete`, { 429 | method: 'POST', 430 | headers: getHeaders(), 431 | body: JSON.stringify({ 432 | paths: [folderPath], 433 | descendants_only: true, // Delete only the contents, not the folder itself 434 | recursive: true // Delete all subdirectories and files 435 | }) 436 | }); 437 | 438 | const deleteData = await deleteResponse.json(); 439 | if (deleteResponse.ok && Object.keys(deleteData).length == 0) { 440 | console.log(chalk.green(`Successfully deleted all contents from: ${chalk.cyan(folderPath)}`)); 441 | } else { 442 | console.log(chalk.red('Failed to delete folder. Please check your input.')); 443 | } 444 | } catch (error) { 445 | console.log(chalk.red('Failed to delete folder.')); 446 | console.error(chalk.red(`Error: ${error.message}`)); 447 | } 448 | } 449 | 450 | /** 451 | * Empty the Trash (wrapper for deleteFolder). 452 | * @param {boolean} skipConfirmation - Whether to skip the confirmation prompt. 453 | */ 454 | export async function emptyTrash(skipConfirmation = true) { 455 | const trashPath = `/${getCurrentUserName()}/Trash`; 456 | await deleteFolder(trashPath, skipConfirmation); 457 | } 458 | 459 | /** 460 | * Show statistical information about the current working directory. 461 | * @param {Array} args array of path names 462 | */ 463 | export async function getInfo(args = []) { 464 | const names = args.length > 0 ? args : ['.']; 465 | for (let name of names) 466 | try { 467 | name = `${getCurrentDirectory()}/${name}`; 468 | console.log(chalk.green(`Getting stat info for: "${name}"...\n`)); 469 | const response = await fetch(`${API_BASE}/stat`, { 470 | method: 'POST', 471 | headers: getHeaders(), 472 | body: JSON.stringify({ 473 | path: name 474 | }) 475 | }); 476 | const data = await response.json(); 477 | if (response.ok && data) { 478 | console.log(chalk.cyan('File/Directory Information:')); 479 | console.log(chalk.dim('----------------------------------------')); 480 | console.log(chalk.cyan(`Name: `) + chalk.white(data.name)); 481 | console.log(chalk.cyan(`Path: `) + chalk.white(data.path)); 482 | console.log(chalk.cyan(`Type: `) + chalk.white(data.is_dir ? 'Directory' : 'File')); 483 | console.log(chalk.cyan(`Size: `) + chalk.white(data.size ? formatSize(data.size) : 'N/A')); 484 | console.log(chalk.cyan(`Created: `) + chalk.white(new Date(data.created * 1000).toLocaleString())); 485 | console.log(chalk.cyan(`Modified: `) + chalk.white(new Date(data.modified * 1000).toLocaleString())); 486 | console.log(chalk.cyan(`Writable: `) + chalk.white(data.writable ? 'Yes' : 'No')); 487 | console.log(chalk.cyan(`Owner: `) + chalk.white(data.owner.username)); 488 | console.log(chalk.dim('----------------------------------------')); 489 | } else { 490 | console.error(chalk.red('Unable to get stat info. Please check your credentials.')); 491 | } 492 | } catch (error) { 493 | console.error(chalk.red(`Failed to get stat info.\nError: ${error.message}`)); 494 | } 495 | } 496 | 497 | /** 498 | * Show the current working directory 499 | */ 500 | export async function showCwd() { 501 | console.log(chalk.green(`${config.get('cwd')}`)); 502 | } 503 | 504 | /** 505 | * Change the current working directory 506 | * @param {Array} args - The path arguments 507 | * @returns void 508 | */ 509 | export async function changeDirectory(args) { 510 | let currentPath = config.get('cwd'); 511 | // If no arguments, print the current directory 512 | if (!args.length) { 513 | console.log(chalk.green(currentPath)); 514 | return; 515 | } 516 | 517 | const path = args[0]; 518 | // Handle "/","~",".." and deeper navigation 519 | const newPath = path.startsWith('/')? path: (path === '~'? `/${getCurrentUserName()}` :resolvePath(currentPath, path)); 520 | try { 521 | // Check if the new path is a valid directory 522 | const response = await fetch(`${API_BASE}/stat`, { 523 | method: 'POST', 524 | headers: getHeaders(), 525 | body: JSON.stringify({ 526 | path: newPath 527 | }) 528 | }); 529 | 530 | const data = await response.json(); 531 | if (response.ok && data && data.is_dir) { 532 | // Update the newPath to use the correct name from the response 533 | const arrayDirs = newPath.split('/'); 534 | arrayDirs.pop(); 535 | arrayDirs.push(data.name); 536 | updatePrompt(arrayDirs.join('/')); // Update the shell prompt 537 | } else { 538 | console.log(chalk.red(`"${newPath}" is not a directory`)); 539 | } 540 | } catch (error) { 541 | console.log(chalk.red(`Cannot access "${newPath}": ${error.message}`)); 542 | } 543 | } 544 | 545 | /** 546 | * Fetch disk usage information 547 | * @param {Object} body - Optional arguments to include in the request body. 548 | */ 549 | export async function getDiskUsage(body = null) { 550 | console.log(chalk.green('Fetching disk usage information...\n')); 551 | try { 552 | const response = await fetch(`${API_BASE}/df`, { 553 | method: 'POST', 554 | headers: getHeaders(), 555 | body: body ? JSON.stringify(body) : null 556 | }); 557 | 558 | const data = await response.json(); 559 | if (response.ok && data) { 560 | showDiskSpaceUsage(data); 561 | } else { 562 | console.error(chalk.red('Unable to fetch disk usage information.')); 563 | } 564 | } catch (error) { 565 | console.error(chalk.red(`Failed to fetch disk usage information.\nError: ${error.message}`)); 566 | } 567 | } 568 | 569 | /** 570 | * Check if a path exists 571 | * @param {string} filePath List of files/directories 572 | */ 573 | export async function pathExists(filePath) { 574 | if (filePath.length < 1) { 575 | console.log(chalk.red('No path provided.')); 576 | return false; 577 | } 578 | try { 579 | // Step 1: Check if the file already exists 580 | const statResponse = await fetch(`${API_BASE}/stat`, { 581 | method: 'POST', 582 | headers: getHeaders(), 583 | body: JSON.stringify({ 584 | path: filePath 585 | }) 586 | }); 587 | 588 | return statResponse.ok; 589 | } catch (error){ 590 | console.error(chalk.red('Failed to check if file exists.')); 591 | return false; 592 | } 593 | } 594 | 595 | /** 596 | * Create a new file (similar to Unix "touch" command). 597 | * @param {Array} args - The arguments passed to the command (file name and optional content). 598 | * @returns {boolean} - True if the file was created successfully, false otherwise. 599 | */ 600 | export async function createFile(args = []) { 601 | if (args.length < 1) { 602 | console.log(chalk.red('Usage: touch [content]')); 603 | return false; 604 | } 605 | 606 | const filePath = args[0]; // File path (e.g., "app/index.html") 607 | const content = args.length > 1 ? args.slice(1).join(' ') : ''; // Optional content 608 | let fullPath = filePath; 609 | if (!filePath.startsWith(`/${getCurrentUserName()}/`)){ 610 | fullPath = resolvePath(getCurrentDirectory(), filePath); // Resolve the full path 611 | } 612 | const dirName = path.dirname(fullPath); // Extract the directory name 613 | const fileName = path.basename(fullPath); // Extract the file name 614 | const dedupeName = false; // Default: false 615 | const overwrite = true; // Default: true 616 | 617 | console.log(chalk.green(`Creating file:\nFileName: "${chalk.dim(fileName)}"\nPath: "${chalk.dim(dirName)}"\nContent Length: ${chalk.dim(content.length)}`)); 618 | try { 619 | // Step 1: Check if the file already exists 620 | const statResponse = await fetch(`${API_BASE}/stat`, { 621 | method: 'POST', 622 | headers: getHeaders(), 623 | body: JSON.stringify({ 624 | path: fullPath 625 | }) 626 | }); 627 | 628 | if (statResponse.ok) { 629 | const statData = await statResponse.json(); 630 | if (statData && statData.id) { 631 | if (!overwrite) { 632 | console.error(chalk.red(`File "${filePath}" already exists. Use --overwrite=true to replace it.`)); 633 | return false; 634 | } 635 | console.log(chalk.yellow(`File "${filePath}" already exists. It will be overwritten.`)); 636 | } 637 | } else if (statResponse.status !== 404) { 638 | console.error(chalk.red('Failed to check if file exists.')); 639 | return false; 640 | } 641 | 642 | // Step 2: Check disk space 643 | const dfResponse = await fetch(`${API_BASE}/df`, { 644 | method: 'POST', 645 | headers: getHeaders(), 646 | body: null 647 | }); 648 | 649 | if (!dfResponse.ok) { 650 | console.error(chalk.red('Unable to check disk space.')); 651 | return false; 652 | } 653 | 654 | const dfData = await dfResponse.json(); 655 | if (dfData.used >= dfData.capacity) { 656 | console.error(chalk.red('Not enough disk space to create the file.')); 657 | showDiskSpaceUsage(dfData); // Display disk usage info 658 | return false; 659 | } 660 | 661 | // Step 3: Create the nested directories if they don't exist 662 | const dirStatResponse = await fetch(`${API_BASE}/stat`, { 663 | method: 'POST', 664 | headers: getHeaders(), 665 | body: JSON.stringify({ 666 | path: dirName 667 | }) 668 | }); 669 | 670 | if (!dirStatResponse.ok || dirStatResponse.status === 404) { 671 | // Create the directory if it doesn't exist 672 | await fetch(`${API_BASE}/mkdir`, { 673 | method: 'POST', 674 | headers: getHeaders(), 675 | body: JSON.stringify({ 676 | parent: path.dirname(dirName), 677 | path: path.basename(dirName), 678 | overwrite: false, 679 | dedupe_name: true, 680 | create_missing_parents: true 681 | }) 682 | }); 683 | } 684 | 685 | // Step 4: Create the file using /batch 686 | const operationId = crypto.randomUUID(); // Generate a unique operation ID 687 | const socketId = 'undefined'; // Placeholder socket ID 688 | const boundary = `----WebKitFormBoundary${crypto.randomUUID().replace(/-/g, '')}`; 689 | const fileBlob = new Blob([content || ''], { type: 'text/plain' }); 690 | 691 | const formData = `--${boundary}\r\n` + 692 | `Content-Disposition: form-data; name="operation_id"\r\n\r\n${operationId}\r\n` + 693 | `--${boundary}\r\n` + 694 | `Content-Disposition: form-data; name="socket_id"\r\n\r\n${socketId}\r\n` + 695 | `--${boundary}\r\n` + 696 | `Content-Disposition: form-data; name="original_client_socket_id"\r\n\r\n${socketId}\r\n` + 697 | `--${boundary}\r\n` + 698 | `Content-Disposition: form-data; name="fileinfo"\r\n\r\n${JSON.stringify({ 699 | name: fileName, 700 | type: 'text/plain', 701 | size: fileBlob.size 702 | })}\r\n` + 703 | `--${boundary}\r\n` + 704 | `Content-Disposition: form-data; name="operation"\r\n\r\n${JSON.stringify({ 705 | op: 'write', 706 | dedupe_name: dedupeName, 707 | overwrite: overwrite, 708 | operation_id: operationId, 709 | path: dirName, 710 | name: fileName, 711 | item_upload_id: 0 712 | })}\r\n` + 713 | `--${boundary}\r\n` + 714 | `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` + 715 | `Content-Type: text/plain\r\n\r\n${content || ''}\r\n` + 716 | `--${boundary}--\r\n`; 717 | 718 | // Send the request 719 | const createResponse = await fetch(`${API_BASE}/batch`, { 720 | method: 'POST', 721 | headers: getHeaders(`multipart/form-data; boundary=${boundary}`), 722 | body: formData 723 | }); 724 | 725 | if (!createResponse.ok) { 726 | const errorText = await createResponse.text(); 727 | console.error(chalk.red(`Failed to create file. Server response: ${errorText}. status: ${createResponse.status}`)); 728 | return false; 729 | } 730 | 731 | const createData = await createResponse.json(); 732 | if (createData && createData.results && createData.results.length > 0) { 733 | const file = createData.results[0]; 734 | console.log(chalk.green(`File "${filePath}" created successfully!`)); 735 | console.log(chalk.dim(`Path: ${file.path}`)); 736 | console.log(chalk.dim(`UID: ${file.uid}`)); 737 | } else { 738 | console.error(chalk.red('Failed to create file. Invalid response from server.')); 739 | return false; 740 | } 741 | } catch (error) { 742 | console.error(chalk.red(`Failed to create file.\nError: ${error.message}`)); 743 | return false; 744 | } 745 | return true; 746 | } 747 | 748 | /** 749 | * Read and display the content of a file (similar to Unix "cat" command). 750 | * @param {Array} args - The arguments passed to the command (file path). 751 | */ 752 | export async function readFile(args = []) { 753 | if (args.length < 1) { 754 | console.log(chalk.red('Usage: cat ')); 755 | return; 756 | } 757 | 758 | const filePath = resolvePath(getCurrentDirectory(), args[0]); 759 | console.log(chalk.green(`Reading file "${filePath}"...\n`)); 760 | 761 | try { 762 | // Step 1: Fetch the file content 763 | const response = await fetch(`${API_BASE}/read?file=${encodeURIComponent(filePath)}`, { 764 | method: 'GET', 765 | headers: getHeaders() 766 | }); 767 | 768 | if (!response.ok) { 769 | console.error(chalk.red(`Failed to read file. Server response: ${response.statusText}`)); 770 | return; 771 | } 772 | 773 | const data = await response.text(); 774 | 775 | // Step 2: Dispaly the content 776 | if (data.length) { 777 | console.log(chalk.cyan(data)); 778 | } else { 779 | console.error(chalk.red('File is empty.')); 780 | } 781 | } catch (error) { 782 | console.error(chalk.red(`Failed to read file.\nError: ${error.message}`)); 783 | } 784 | } 785 | 786 | /** 787 | * Upload a file from the host machine to the Puter server 788 | * @param {Array} args - The arguments passed to the command: ( [remote_path] [dedupe_name] [overwrite]) 789 | */ 790 | export async function uploadFile(args = []) { 791 | if (args.length < 1) { 792 | console.log(chalk.red('Usage: push [remote_path] [dedupe_name] [overwrite]')); 793 | return; 794 | } 795 | 796 | const localPath = args[0]; 797 | let remotePath = ''; 798 | if (args.length > 1){ 799 | remotePath = args[1].startsWith('/')? args[1]: resolvePath(getCurrentDirectory(), args[1]); 800 | } else { 801 | remotePath = resolvePath(getCurrentDirectory(), '.'); 802 | } 803 | const dedupeName = args.length > 2 ? args[2] === 'true' : true; // Default: true 804 | const overwrite = args.length > 3 ? args[3] === 'true' : false; // Default: false 805 | 806 | console.log(chalk.green(`Uploading files from "${localPath}" to "${remotePath}"...\n`)); 807 | try { 808 | // Step 1: Find all matching files (excluding hidden files) 809 | const files = glob.sync(localPath, { nodir: true, dot: false }); 810 | 811 | if (files.length === 0) { 812 | console.error(chalk.red('No files found to upload.')); 813 | return; 814 | } 815 | 816 | // Step 2: Check disk space 817 | const dfResponse = await fetch(`${API_BASE}/df`, { 818 | method: 'POST', 819 | headers: getHeaders(), 820 | body: null 821 | }); 822 | 823 | if (!dfResponse.ok) { 824 | console.error(chalk.red('Unable to check disk space.')); 825 | return; 826 | } 827 | 828 | const dfData = await dfResponse.json(); 829 | if (dfData.used >= dfData.capacity) { 830 | console.error(chalk.red('Not enough disk space to upload the files.')); 831 | showDiskSpaceUsage(dfData); // Display disk usage info 832 | return; 833 | } 834 | 835 | // Step 3: Upload each file 836 | for (const filePath of files) { 837 | const fileName = path.basename(filePath); 838 | const fileContent = fs.readFileSync(filePath, 'utf8'); 839 | 840 | // Prepare the upload request 841 | const operationId = crypto.randomUUID(); // Generate a unique operation ID 842 | const socketId = 'undefined'; // Placeholder socket ID 843 | const boundary = `----WebKitFormBoundary${crypto.randomUUID().replace(/-/g, '')}`; 844 | 845 | // Prepare FormData 846 | const formData = `--${boundary}\r\n` + 847 | `Content-Disposition: form-data; name="operation_id"\r\n\r\n${operationId}\r\n` + 848 | `--${boundary}\r\n` + 849 | `Content-Disposition: form-data; name="socket_id"\r\n\r\n${socketId}\r\n` + 850 | `--${boundary}\r\n` + 851 | `Content-Disposition: form-data; name="original_client_socket_id"\r\n\r\n${socketId}\r\n` + 852 | `--${boundary}\r\n` + 853 | `Content-Disposition: form-data; name="fileinfo"\r\n\r\n${JSON.stringify({ 854 | name: fileName, 855 | type: 'text/plain', 856 | size: Buffer.byteLength(fileContent, 'utf8') 857 | })}\r\n` + 858 | `--${boundary}\r\n` + 859 | `Content-Disposition: form-data; name="operation"\r\n\r\n${JSON.stringify({ 860 | op: 'write', 861 | dedupe_name: dedupeName, 862 | overwrite: overwrite, 863 | operation_id: operationId, 864 | path: remotePath, 865 | name: fileName, 866 | item_upload_id: 0 867 | })}\r\n` + 868 | `--${boundary}\r\n` + 869 | `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` + 870 | `Content-Type: text/plain\r\n\r\n${fileContent}\r\n` + 871 | `--${boundary}--\r\n`; 872 | 873 | // Send the upload request 874 | const uploadResponse = await fetch(`${API_BASE}/batch`, { 875 | method: 'POST', 876 | headers: getHeaders(`multipart/form-data; boundary=${boundary}`), 877 | body: formData 878 | }); 879 | 880 | if (!uploadResponse.ok) { 881 | const errorText = await uploadResponse.text(); 882 | console.error(chalk.red(`Failed to upload file "${fileName}". Server response: ${errorText}`)); 883 | continue; 884 | } 885 | 886 | const uploadData = await uploadResponse.json(); 887 | if (uploadData && uploadData.results && uploadData.results.length > 0) { 888 | const file = uploadData.results[0]; 889 | console.log(chalk.green(`File "${fileName}" uploaded successfully!`)); 890 | console.log(chalk.dim(`Path: ${file.path}`)); 891 | console.log(chalk.dim(`UID: ${file.uid}`)); 892 | } else { 893 | console.error(chalk.red(`Failed to upload file "${fileName}". Invalid response from server.`)); 894 | } 895 | } 896 | } catch (error) { 897 | console.error(chalk.red(`Failed to upload files.\nError: ${error.message}`)); 898 | } 899 | } 900 | 901 | /** 902 | * Get a temporary CSRF Token 903 | * @returns The CSRF token 904 | */ 905 | async function getCsrfToken() { 906 | const csrfResponse = await fetch(`${BASE_URL}/get-anticsrf-token`, { 907 | method: 'GET', 908 | headers: getHeaders() 909 | }); 910 | 911 | if (!csrfResponse.ok) { 912 | console.error(chalk.red('Failed to fetch CSRF token.')); 913 | return; 914 | } 915 | 916 | const csrfData = await csrfResponse.json(); 917 | if (!csrfData || !csrfData.token) { 918 | console.error(chalk.red('Failed to fetch anti-CSRF token.')); 919 | return; 920 | } 921 | 922 | return csrfData.token; 923 | } 924 | 925 | /** 926 | * Download a file from the Puter server to the host machine 927 | * @param {Array} args - The arguments passed to the command (remote file path, Optional: local path). 928 | */ 929 | export async function downloadFile(args = []) { 930 | if (args.length < 1) { 931 | console.log(chalk.red('Usage: pull [local_path] [overwrite]')); 932 | return; 933 | } 934 | 935 | const remotePathPattern = resolvePath(getCurrentDirectory(), args[0]); // Resolve the remote file path pattern 936 | const localBasePath = path.dirname(args.length > 1 ? args[1] : '.'); // Default to the current directory 937 | const overwrite = args.length > 2 ? args[2] === 'true' : false; // Default: false 938 | 939 | console.log(chalk.green(`Downloading files matching "${remotePathPattern}" to "${localBasePath}"...\n`)); 940 | 941 | try { 942 | // Step 1: Fetch the list of files and directories from the server 943 | const listResponse = await fetch(`${API_BASE}/readdir`, { 944 | method: 'POST', 945 | headers: getHeaders(), 946 | body: JSON.stringify({ path: getCurrentDirectory() }) 947 | }); 948 | 949 | if (!listResponse.ok) { 950 | console.error(chalk.red('Failed to list files from the server.')); 951 | return; 952 | } 953 | 954 | const files = await listResponse.json(); 955 | if (!Array.isArray(files) || files.length === 0) { 956 | console.error(chalk.red('No files or directories found on the server.')); 957 | return; 958 | } 959 | 960 | // Step 2: Recursively find files matching the pattern 961 | const matchedFiles = await findMatchingFiles(files, remotePathPattern, getCurrentDirectory()); 962 | 963 | if (matchedFiles.length === 0) { 964 | console.error(chalk.red('No files found matching the pattern.')); 965 | return; 966 | } 967 | 968 | // Step 3: Download each matched file 969 | for (const remoteFilePath of matchedFiles) { 970 | const relativePath = path.relative(getCurrentDirectory(), remoteFilePath); 971 | const localFilePath = path.join(localBasePath, relativePath); 972 | 973 | // Ensure the local directory exists 974 | if (!fs.existsSync(path.dirname(localFilePath))){ 975 | fs.mkdirSync(path.dirname(localFilePath), { recursive: true }); 976 | } 977 | 978 | console.log(chalk.green(`Downloading file "${remoteFilePath}" to "${localFilePath}"...`)); 979 | 980 | // Fetch the anti-CSRF token 981 | const antiCsrfToken = await getCsrfToken(); 982 | 983 | const downloadResponse = await fetch(`${BASE_URL}/down?path=${remoteFilePath}`, { 984 | method: 'POST', 985 | headers: { 986 | ...getHeaders('application/x-www-form-urlencoded'), 987 | "cookie": `puter_auth_token=${getAuthToken()};` 988 | }, 989 | "referrerPolicy": "strict-origin-when-cross-origin", 990 | body: `anti_csrf=${antiCsrfToken}` 991 | }); 992 | 993 | if (!downloadResponse.ok) { 994 | console.error(chalk.red(`Failed to download file "${remoteFilePath}". Server response: ${downloadResponse.statusText}`)); 995 | continue; 996 | } 997 | 998 | // Step 5: Save the file content to the local filesystem 999 | const fileContent = await downloadResponse.text(); 1000 | 1001 | // Check if the file exists, if so then delete it before writing. 1002 | if (overwrite && fs.existsSync(localFilePath)) { 1003 | fs.unlinkSync(localFilePath); 1004 | console.log(chalk.yellow(`File "${localFilePath}" already exists. Overwriting...`)); 1005 | } 1006 | 1007 | fs.writeFileSync(localFilePath, fileContent, 'utf8'); 1008 | const fileSize = fs.statSync(localFilePath).size; 1009 | console.log(chalk.green(`File: "${remoteFilePath}" downloaded to "${localFilePath}" (size: ${formatSize(fileSize)})`)); 1010 | } 1011 | } catch (error) { 1012 | console.error(chalk.red(`Failed to download files.\nError: ${error.message}`)); 1013 | } 1014 | } 1015 | 1016 | /** 1017 | * Copy files or directories from one location to another on the Puter server (similar to Unix "cp" command). 1018 | * @param {Array} args - The arguments passed to the command (source path, destination path). 1019 | */ 1020 | export async function copyFile(args = []) { 1021 | if (args.length < 2) { 1022 | console.log(chalk.red('Usage: cp ')); 1023 | return; 1024 | } 1025 | 1026 | const sourcePath = args[0].startsWith(`/${getCurrentUserName()}`) ? args[0] : resolvePath(getCurrentDirectory(), args[0]); // Resolve the source path 1027 | const destinationPath = args[1].startsWith(`/${getCurrentUserName()}`) ? args[1] : resolvePath(getCurrentDirectory(), args[1]); // Resolve the destination path 1028 | 1029 | console.log(chalk.green(`Copy: "${chalk.dim(sourcePath)}" to: "${chalk.dim(destinationPath)}"...\n`)); 1030 | try { 1031 | // Step 1: Check if the source is a directory or a file 1032 | const statResponse = await fetch(`${API_BASE}/stat`, { 1033 | method: 'POST', 1034 | headers: getHeaders(), 1035 | body: JSON.stringify({ 1036 | path: sourcePath 1037 | }) 1038 | }); 1039 | 1040 | if (!statResponse.ok) { 1041 | console.error(chalk.red(`Failed to check source path. Server response: ${await statResponse.text()}`)); 1042 | return; 1043 | } 1044 | 1045 | const statData = await statResponse.json(); 1046 | if (!statData || !statData.id) { 1047 | console.error(chalk.red(`Source path "${sourcePath}" does not exist.`)); 1048 | return; 1049 | } 1050 | 1051 | if (statData.is_dir) { 1052 | // Step 2: If source is a directory, copy all files recursively 1053 | const files = await listFiles([sourcePath]); 1054 | for (const file of files) { 1055 | const relativePath = file.path.replace(sourcePath, ''); 1056 | const destPath = path.join(destinationPath, relativePath); 1057 | 1058 | const copyResponse = await fetch(`${API_BASE}/copy`, { 1059 | method: 'POST', 1060 | headers: getHeaders(), 1061 | body: JSON.stringify({ 1062 | source: file.path, 1063 | destination: destPath 1064 | }) 1065 | }); 1066 | 1067 | if (!copyResponse.ok) { 1068 | console.error(chalk.red(`Failed to copy file "${file.path}". Server response: ${await copyResponse.text()}`)); 1069 | continue; 1070 | } 1071 | 1072 | const copyData = await copyResponse.json(); 1073 | if (copyData && copyData.length > 0 && copyData[0].copied) { 1074 | console.log(chalk.green(`File "${chalk.dim(file.path)}" copied successfully to "${chalk.dim(copyData[0].copied.path)}"!`)); 1075 | } else { 1076 | console.error(chalk.red(`Failed to copy file "${file.path}". Invalid response from server.`)); 1077 | } 1078 | } 1079 | } else { 1080 | // Step 3: If source is a file, copy it directly 1081 | const copyResponse = await fetch(`${API_BASE}/copy`, { 1082 | method: 'POST', 1083 | headers: getHeaders(), 1084 | body: JSON.stringify({ 1085 | source: sourcePath, 1086 | destination: destinationPath 1087 | }) 1088 | }); 1089 | 1090 | if (!copyResponse.ok) { 1091 | console.error(chalk.red(`Failed to copy file. Server response: ${await copyResponse.text()}`)); 1092 | return; 1093 | } 1094 | 1095 | const copyData = await copyResponse.json(); 1096 | if (copyData && copyData.length > 0 && copyData[0].copied) { 1097 | console.log(chalk.green(`File "${sourcePath}" copied successfully to "${copyData[0].copied.path}"!`)); 1098 | console.log(chalk.dim(`UID: ${copyData[0].copied.uid}`)); 1099 | } else { 1100 | console.error(chalk.red('Failed to copy file. Invalid response from server.')); 1101 | } 1102 | } 1103 | } catch (error) { 1104 | console.error(chalk.red(`Failed to copy file.\nError: ${error.message}`)); 1105 | } 1106 | } 1107 | 1108 | 1109 | /** 1110 | * List all files in a local directory. 1111 | * @param {string} localDir - The local directory path. 1112 | * @param {boolean} recursive - Whether to recursively list files in subdirectories 1113 | * @returns {Array} - Array of local file objects. 1114 | */ 1115 | function listLocalFiles(localDir, recursive = false) { 1116 | const files = []; 1117 | const walkDir = (dir, baseDir) => { 1118 | 1119 | const entries = fs.readdirSync(dir, { withFileTypes: true }); 1120 | for (const entry of entries) { 1121 | const fullPath = path.join(dir, entry.name); 1122 | const relativePath = path.relative(baseDir, fullPath); 1123 | if (entry.isDirectory()) { 1124 | if (recursive) { 1125 | walkDir(fullPath, baseDir); // Recursively traverse directories if flag is set 1126 | } 1127 | } else { 1128 | files.push({ 1129 | relativePath: relativePath, 1130 | localPath: fullPath, 1131 | size: fs.statSync(fullPath).size, 1132 | modified: fs.statSync(fullPath).mtime.getTime() 1133 | }); 1134 | } 1135 | } 1136 | 1137 | }; 1138 | 1139 | walkDir(localDir, localDir); 1140 | return files; 1141 | } 1142 | 1143 | /** 1144 | * Compare local and remote files to determine actions. 1145 | * @param {Array} localFiles - Array of local file objects. 1146 | * @param {Array} remoteFiles - Array of remote file objects. 1147 | * @param {string} localDir - Local directory path. 1148 | * @param {string} remoteDir - Remote directory path. 1149 | * @returns {Object} - Object containing files to upload, download, and delete. 1150 | */ 1151 | function compareFiles(localFiles, remoteFiles, localDir, remoteDir) { 1152 | const toUpload = []; // Files to upload to remote 1153 | const toDownload = []; // Files to download from remote 1154 | const toDelete = []; // Files to delete from remote 1155 | 1156 | // Create a map of remote files for quick lookup 1157 | const remoteFileMap = new Map(); 1158 | remoteFiles.forEach(file => { 1159 | remoteFileMap.set(file.name, { 1160 | size: file.size, 1161 | modified: new Date(file.modified).getTime() 1162 | }); 1163 | }); 1164 | 1165 | // Check local files 1166 | for (const file of localFiles) { 1167 | const remoteFile = remoteFileMap.get(file.relativePath); 1168 | if (!remoteFile || file.modified > remoteFile.modified) { 1169 | toUpload.push(file); // New or updated file 1170 | } 1171 | } 1172 | 1173 | // Check remote files 1174 | for (const file of remoteFiles) { 1175 | const localFile = localFiles.find(f => f.relativePath === file.name); 1176 | if (localFile){ 1177 | console.log(`localFile: ${localFile.relativePath}, modified: ${localFile.modified}`); 1178 | } 1179 | console.log(`file: ${file.name}, modified: ${file.modified}`); 1180 | if (!localFile) { 1181 | toDelete.push({ relativePath: file.name }); // Extra file in remote 1182 | } else if (file.modified > parseInt(localFile.modified / 1000)) { 1183 | toDownload.push(localFile); // New or updated file in remote 1184 | } 1185 | } 1186 | 1187 | return { toUpload, toDownload, toDelete }; 1188 | } 1189 | 1190 | /** 1191 | * Find conflicts where the same file has been modified in both locations. 1192 | * @param {Array} toUpload - Files to upload. 1193 | * @param {Array} toDownload - Files to download. 1194 | * @returns {Array} - Array of conflicting file paths. 1195 | */ 1196 | function findConflicts(toUpload, toDownload) { 1197 | const conflicts = []; 1198 | const uploadPaths = toUpload.map(file => file.relativePath); 1199 | const downloadPaths = toDownload.map(file => file.relativePath); 1200 | 1201 | for (const path of uploadPaths) { 1202 | if (downloadPaths.includes(path)) { 1203 | conflicts.push(path); 1204 | } 1205 | } 1206 | 1207 | return conflicts; 1208 | } 1209 | 1210 | /** 1211 | * Resolve given local path directory 1212 | * @param {string} localPath The local path to resolve 1213 | * @returns {Promise} The resolved absolute path 1214 | * @throws {Error} If the path does not exist or is not a directory 1215 | */ 1216 | async function resolveLocalDirectory(localPath) { 1217 | // Resolve the path to an absolute path 1218 | const absolutePath = path.resolve(localPath); 1219 | 1220 | // Check if the path exists 1221 | if (!fs.existsSync(absolutePath)) { 1222 | throw new Error(`Path does not exist: ${absolutePath}`); 1223 | } 1224 | 1225 | // Check if the path is a directory 1226 | const stats = await fs.promises.stat(absolutePath); 1227 | if (!stats.isDirectory()) { 1228 | throw new Error(`Path is not a directory: ${absolutePath}`); 1229 | } 1230 | return absolutePath; 1231 | } 1232 | 1233 | /** 1234 | * Ensure a remote directory exists, creating it if necessary 1235 | * @param {string} remotePath - The remote directory path 1236 | */ 1237 | async function ensureRemoteDirectoryExists(remotePath) { 1238 | try { 1239 | const exists = await pathExists(remotePath); 1240 | if (!exists) { 1241 | // Create the directory and any missing parents 1242 | await fetch(`${API_BASE}/mkdir`, { 1243 | method: 'POST', 1244 | headers: getHeaders(), 1245 | body: JSON.stringify({ 1246 | parent: path.dirname(remotePath), 1247 | path: path.basename(remotePath), 1248 | overwrite: false, 1249 | dedupe_name: true, 1250 | create_missing_parents: true 1251 | }) 1252 | }); 1253 | } 1254 | } catch (error) { 1255 | console.error(chalk.red(`Failed to create remote directory: ${remotePath}`)); 1256 | throw error; 1257 | } 1258 | } 1259 | 1260 | /** 1261 | * Synchronize a local directory with a remote directory on Puter. 1262 | * @param {string[]} args - Command-line arguments (e.g., [localDir, remoteDir, --delete, -r]). 1263 | */ 1264 | export async function syncDirectory(args = []) { 1265 | const usageMessage = 'Usage: update [--delete] [-r]'; 1266 | if (args.length < 2) { 1267 | console.log(chalk.red(usageMessage)); 1268 | return; 1269 | } 1270 | 1271 | let localDir = ''; 1272 | let remoteDir = ''; 1273 | let deleteFlag = ''; 1274 | let recursiveFlag = false; 1275 | try { 1276 | localDir = await resolveLocalDirectory(args[0]); 1277 | remoteDir = resolvePath(getCurrentDirectory(), args[1]); 1278 | deleteFlag = args.includes('--delete'); // Whether to delete extra files 1279 | recursiveFlag = args.includes('-r'); // Whether to recursively process subdirectories 1280 | } catch (error) { 1281 | console.error(chalk.red(error.message)); 1282 | console.log(chalk.green(usageMessage)); 1283 | return; 1284 | } 1285 | 1286 | console.log(chalk.green(`Syncing local directory ${chalk.cyan(localDir)}" with remote directory ${chalk.cyan(remoteDir)}"...\n`)); 1287 | 1288 | try { 1289 | // Step 1: Validate local directory 1290 | if (!fs.existsSync(localDir)) { 1291 | console.error(chalk.red(`Local directory "${localDir}" does not exist.`)); 1292 | return; 1293 | } 1294 | 1295 | // Step 2: Fetch remote directory contents 1296 | const remoteFiles = await listRemoteFiles(remoteDir); 1297 | if (!Array.isArray(remoteFiles)) { 1298 | console.error(chalk.red('Failed to fetch remote directory contents.')); 1299 | return; 1300 | } 1301 | 1302 | // Step 3: List local files 1303 | const localFiles = listLocalFiles(localDir, recursiveFlag); 1304 | 1305 | // Step 4: Compare local and remote files 1306 | let { toUpload, toDownload, toDelete } = compareFiles(localFiles, remoteFiles, localDir, remoteDir); 1307 | let filteredToUpload = [...toUpload]; 1308 | let filteredToDownload = [...toDownload]; 1309 | 1310 | // Step 5: Handle conflicts (if any) 1311 | const conflicts = findConflicts(toUpload, toDownload); 1312 | if (conflicts.length > 0) { 1313 | 1314 | console.log(chalk.yellow('The following files have conflicts:')); 1315 | conflicts.forEach(file => console.log(chalk.dim(`- ${file}`))); 1316 | 1317 | const { resolve } = await inquirer.prompt([ 1318 | { 1319 | type: 'list', 1320 | name: 'resolve', 1321 | message: 'How would you like to resolve conflicts?', 1322 | choices: [ 1323 | { name: 'Keep local version', value: 'local' }, 1324 | { name: 'Keep remote version', value: 'remote' }, 1325 | { name: 'Skip conflicting files', value: 'skip' } 1326 | ] 1327 | } 1328 | ]); 1329 | 1330 | if (resolve === 'local') { 1331 | filteredToDownload = filteredToDownload.filter(file => !conflicts.includes(file.relativePath)); 1332 | } else if (resolve === 'remote') { 1333 | filteredToUpload = filteredToUpload.filter(file => !conflicts.includes(file.relativePath)); 1334 | } else { 1335 | filteredToUpload = filteredToUpload.filter(file => !conflicts.includes(file.relativePath)); 1336 | filteredToDownload = filteredToDownload.filter(file => !conflicts.includes(file.relativePath)); 1337 | } 1338 | } 1339 | 1340 | // Step 6: Perform synchronization 1341 | console.log(chalk.green('Starting synchronization...')); 1342 | 1343 | // Upload new/updated files 1344 | for (const file of filteredToUpload) { 1345 | console.log(chalk.cyan(`Uploading "${file.relativePath}"...`)); 1346 | const dedupeName = 'false'; 1347 | const overwrite = 'true'; 1348 | 1349 | // Create parent directories if needed 1350 | const remoteFilePath = path.join(remoteDir, file.relativePath); 1351 | const remoteFileDir = path.dirname(remoteFilePath); 1352 | 1353 | // Ensure remote directory exists 1354 | await ensureRemoteDirectoryExists(remoteFileDir); 1355 | 1356 | await uploadFile([file.localPath, remoteFileDir, dedupeName, overwrite]); 1357 | } 1358 | 1359 | // Download new/updated files 1360 | for (const file of filteredToDownload) { 1361 | console.log(chalk.cyan(`Downloading "${file.relativePath}"...`)); 1362 | const overwrite = 'true'; 1363 | // Create local parent directories if needed 1364 | const localFilePath = path.join(localDir, file.relativePath); 1365 | // const localFileDir = path.dirname(localFilePath); 1366 | 1367 | await downloadFile([file.remotePath, localFilePath, overwrite]); 1368 | } 1369 | 1370 | // Delete extra files (if --delete flag is set) 1371 | if (deleteFlag) { 1372 | for (const file of toDelete) { 1373 | console.log(chalk.yellow(`Deleting "${file.relativePath}"...`)); 1374 | await removeFileOrDirectory([path.join(remoteDir, file.relativePath), '-f']); 1375 | } 1376 | } 1377 | 1378 | console.log(chalk.green('Synchronization complete!')); 1379 | } catch (error) { 1380 | console.error(chalk.red('Failed to synchronize directories.')); 1381 | console.error(chalk.red(`Error: ${error.message}`)); 1382 | } 1383 | } 1384 | 1385 | /** 1386 | * Edit a remote file using the local system editor 1387 | * @param {Array} args - The file path to edit 1388 | * @returns {Promise} 1389 | */ 1390 | export async function editFile(args = []) { 1391 | if (args.length < 1) { 1392 | console.log(chalk.red('Usage: edit ')); 1393 | return; 1394 | } 1395 | 1396 | const filePath = args[0].startsWith('/') ? args[0] : resolvePath(getCurrentDirectory(), args[0]); 1397 | console.log(chalk.green(`Fetching file: ${filePath}`)); 1398 | 1399 | try { 1400 | // Step 1: Check if file exists 1401 | const statResponse = await fetch(`${API_BASE}/stat`, { 1402 | method: 'POST', 1403 | headers: getHeaders(), 1404 | body: JSON.stringify({ path: filePath }) 1405 | }); 1406 | 1407 | const statData = await statResponse.json(); 1408 | if (!statData || !statData.uid || statData.is_dir) { 1409 | console.log(chalk.red(`File not found or is a directory: ${filePath}`)); 1410 | return; 1411 | } 1412 | 1413 | // Step 2: Download the file content 1414 | const downloadResponse = await fetch(`${API_BASE}/read?file=${encodeURIComponent(filePath)}`, { 1415 | method: 'GET', 1416 | headers: getHeaders() 1417 | }); 1418 | 1419 | if (!downloadResponse.ok) { 1420 | console.log(chalk.red(`Failed to download file: ${filePath}`)); 1421 | return; 1422 | } 1423 | 1424 | const fileContent = await downloadResponse.text(); 1425 | console.log(chalk.green(`File fetched: ${filePath} (${formatSize(fileContent.length)} bytes)`)); 1426 | 1427 | // Step 3: Create a temporary file 1428 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puter-')); 1429 | const tempFilePath = path.join(tempDir, path.basename(filePath)); 1430 | fs.writeFileSync(tempFilePath, fileContent, 'utf-8'); 1431 | 1432 | // Step 4: Determine the editor to use 1433 | const editor = getSystemEditor(); 1434 | console.log(chalk.cyan(`Opening file with ${editor}...`)); 1435 | 1436 | // Step 5: Open the file in the editor using execSync instead of spawn 1437 | // This will block until the editor is closed, which is better for terminal-based editors 1438 | try { 1439 | execSync(`${editor} "${tempFilePath}"`, { 1440 | stdio: 'inherit', 1441 | env: process.env 1442 | }); 1443 | 1444 | // Read the updated content after editor closes 1445 | const updatedContent = fs.readFileSync(tempFilePath, 'utf8'); 1446 | console.log(chalk.cyan('Uploading changes...')); 1447 | 1448 | // Step 7: Upload the updated file content 1449 | // Step 7.1: Check disk space 1450 | const dfResponse = await fetch(`${API_BASE}/df`, { 1451 | method: 'POST', 1452 | headers: getHeaders(), 1453 | body: null 1454 | }); 1455 | 1456 | if (!dfResponse.ok) { 1457 | console.log(chalk.red('Unable to check disk space.')); 1458 | return; 1459 | } 1460 | 1461 | const dfData = await dfResponse.json(); 1462 | if (dfData.used >= dfData.capacity) { 1463 | console.log(chalk.red('Not enough disk space to upload the file.')); 1464 | showDiskSpaceUsage(dfData); // Display disk usage info 1465 | return; 1466 | } 1467 | 1468 | // Step 7.2: Uploading the updated file 1469 | const operationId = crypto.randomUUID(); // Generate a unique operation ID 1470 | const socketId = 'undefined'; // Placeholder socket ID 1471 | const boundary = `----WebKitFormBoundary${crypto.randomUUID().replace(/-/g, '')}`; 1472 | const fileName = path.basename(filePath); 1473 | const dirName = path.dirname(filePath); 1474 | 1475 | // Prepare FormData 1476 | const formData = `--${boundary}\r\n` + 1477 | `Content-Disposition: form-data; name="operation_id"\r\n\r\n${operationId}\r\n` + 1478 | `--${boundary}\r\n` + 1479 | `Content-Disposition: form-data; name="socket_id"\r\n\r\n${socketId}\r\n` + 1480 | `--${boundary}\r\n` + 1481 | `Content-Disposition: form-data; name="original_client_socket_id"\r\n\r\n${socketId}\r\n` + 1482 | `--${boundary}\r\n` + 1483 | `Content-Disposition: form-data; name="fileinfo"\r\n\r\n${JSON.stringify({ 1484 | name: fileName, 1485 | type: 'text/plain', 1486 | size: Buffer.byteLength(updatedContent, 'utf8') 1487 | })}\r\n` + 1488 | `--${boundary}\r\n` + 1489 | `Content-Disposition: form-data; name="operation"\r\n\r\n${JSON.stringify({ 1490 | op: 'write', 1491 | dedupe_name: false, 1492 | overwrite: true, 1493 | operation_id: operationId, 1494 | path: dirName, 1495 | name: fileName, 1496 | item_upload_id: 0 1497 | })}\r\n` + 1498 | `--${boundary}\r\n` + 1499 | `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` + 1500 | `Content-Type: text/plain\r\n\r\n${updatedContent}\r\n` + 1501 | `--${boundary}--\r\n`; 1502 | 1503 | // Send the upload request 1504 | const uploadResponse = await fetch(`${API_BASE}/batch`, { 1505 | method: 'POST', 1506 | headers: getHeaders(`multipart/form-data; boundary=${boundary}`), 1507 | body: formData 1508 | }); 1509 | 1510 | if (!uploadResponse.ok) { 1511 | const errorText = await uploadResponse.text(); 1512 | console.log(chalk.red(`Failed to save file. Server response: ${errorText}`)); 1513 | return; 1514 | } 1515 | 1516 | const uploadData = await uploadResponse.json(); 1517 | if (uploadData && uploadData.results && uploadData.results.length > 0) { 1518 | const file = uploadData.results[0]; 1519 | console.log(chalk.green(`File saved: ${file.path}`)); 1520 | } else { 1521 | console.log(chalk.red('Failed to save file. Invalid response from server.')); 1522 | } 1523 | } catch (error) { 1524 | if (error.status === 130) { 1525 | // This is a SIGINT (Ctrl+C), which is normal for some editors 1526 | console.log(chalk.yellow('Editor closed without saving.')); 1527 | } else { 1528 | console.log(chalk.red(`Error during editing: ${error.message}`)); 1529 | } 1530 | } finally { 1531 | // Clean up temporary files 1532 | try { 1533 | fs.unlinkSync(tempFilePath); 1534 | fs.rmdirSync(tempDir); 1535 | } catch (e) { 1536 | console.error(chalk.dim(`Failed to clean up temporary files: ${e.message}`)); 1537 | } 1538 | } 1539 | } catch (error) { 1540 | console.log(chalk.red(`Error: ${error.message}`)); 1541 | } 1542 | } -------------------------------------------------------------------------------- /src/commands/init.js: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | import chalk from 'chalk'; 3 | import ora from 'ora'; 4 | import { promises as fs } from 'fs'; 5 | import path from 'path'; 6 | import { generateAppName, getDefaultHomePage } from '../commons.js'; 7 | 8 | const JS_BUNDLERS = ['Vite', 'Webpack', 'Parcel', 'esbuild', 'Farm']; 9 | const FULLSTACK_FRAMEWORKS = ['Next', 'Nuxt', 'SvelteKit', 'Astro']; 10 | const JS_LIBRARIES = ['React', 'Vue', 'Angular', 'Svelte', 'jQuery']; 11 | const CSS_LIBRARIES = ['Bootstrap', 'Bulma', 'shadcn', 'Tailwind', 'Material-UI', 'Semantic UI', 'AntDesign', 'Element-Plus', 'PostCSS', 'AutoPrefixer']; 12 | 13 | export async function init() { 14 | const answers = await inquirer.prompt([ 15 | { 16 | type: 'input', 17 | name: 'name', 18 | message: 'What is your app name?', 19 | default: `${generateAppName()}` 20 | }, 21 | { 22 | type: 'list', 23 | name: 'useBundler', 24 | message: 'Do you want to use a JavaScript bundler?', 25 | choices: ['Yes', 'No (Use CDN)'] 26 | } 27 | ]); 28 | 29 | let jsFiles = ['puter-sdk']; 30 | let jsDevFiles = []; 31 | let cssFiles = []; 32 | let jsExtraLibraries = []; 33 | let extraFiles = []; 34 | let bundlerAnswers = null; 35 | let frameworkAnswers = null; 36 | 37 | if (answers.useBundler === 'Yes') { 38 | bundlerAnswers = await inquirer.prompt([ 39 | { 40 | type: 'list', 41 | name: 'bundler', 42 | message: 'Select a JavaScript bundler:', 43 | choices: JS_BUNDLERS 44 | }, 45 | { 46 | type: 'list', 47 | name: 'frameworkType', 48 | message: 'Do you want to use a full-stack framework or custom libraries?', 49 | choices: ['Full-stack framework', 'Custom libraries'] 50 | } 51 | ]); 52 | 53 | if (bundlerAnswers.frameworkType === 'Full-stack framework') { 54 | frameworkAnswers = await inquirer.prompt([ 55 | { 56 | type: 'list', 57 | name: 'framework', 58 | message: 'Select a full-stack framework:', 59 | choices: FULLSTACK_FRAMEWORKS 60 | } 61 | ]); 62 | 63 | switch (frameworkAnswers.framework) { 64 | case FULLSTACK_FRAMEWORKS[0]: 65 | jsFiles.push('next@latest'); 66 | extraFiles.push({ 67 | path: 'src/index.tsx', 68 | content: `export default function Home() { return (

${answers.name}

) }` 69 | }); 70 | break; 71 | case FULLSTACK_FRAMEWORKS[1]: 72 | jsFiles.push('nuxt@latest'); 73 | extraFiles.push({ 74 | path: 'src/app.vue', 75 | content: `` 76 | }); 77 | break; 78 | case FULLSTACK_FRAMEWORKS[2]: 79 | jsFiles.push('svelte@latest', 'sveltekit@latest'); 80 | extraFiles.push({ 81 | path: 'src/app.vue', 82 | content: `` 83 | }); 84 | break; 85 | case FULLSTACK_FRAMEWORKS[3]: 86 | jsFiles.push('astro@latest', 'astro@latest'); 87 | extraFiles.push({ 88 | path: 'src/pages/index.astro', 89 | content: `---\n\n

${answers.name}

` 90 | }); 91 | break; 92 | } 93 | } else { 94 | const libraryAnswers = await inquirer.prompt([ 95 | { 96 | type: 'list', 97 | name: 'library', 98 | message: 'Select a JavaScript library/framework:', 99 | choices: JS_LIBRARIES 100 | } 101 | ]); 102 | 103 | switch (libraryAnswers.library) { 104 | case JS_LIBRARIES[0]: 105 | jsFiles.push('react@latest', 'react-dom@latest'); 106 | const reactLibs = await inquirer.prompt([ 107 | { 108 | type: 'checkbox', 109 | name: 'reactLibraries', 110 | message: 'Select React libraries:', 111 | choices: CSS_LIBRARIES.concat(['react-router-dom', 'react-redux', 'react-bootstrap', '@chakra-ui/react', 'semantic-ui-react']) 112 | } 113 | ]); 114 | jsFiles.push(...reactLibs.reactLibraries); 115 | extraFiles.push({ 116 | path: 'src/App.jsx', 117 | content: `export default function Home() { return (

${answers.name}

) }` 118 | }); 119 | break; 120 | case JS_LIBRARIES[1]: 121 | jsFiles.push('vue@latest'); 122 | jsDevFiles.push('@vitejs/plugin-vue'); 123 | const vueLibs = await inquirer.prompt([ 124 | { 125 | type: 'checkbox', 126 | name: 'vueLibraries', 127 | message: 'Select Vue libraries:', 128 | choices: CSS_LIBRARIES.concat(['shadcn-vue', 'UnoCSS', 'NaiveUI', 'bootstrap-vue-next', 'buefy', 'vue-router', 'pinia']) 129 | } 130 | ]); 131 | jsFiles.push(...vueLibs.vueLibraries); 132 | extraFiles.push( 133 | { 134 | path: 'src/App.vue', 135 | content: `` 136 | }, 137 | { 138 | path: 'vite.config.js', 139 | content: `import { defineConfig } from 'vite'; 140 | import vue from '@vitejs/plugin-vue'; 141 | 142 | export default defineConfig({ 143 | plugins: [vue()] 144 | }) 145 | `}, 146 | { 147 | path: 'main.js', 148 | content: `import { createApp } from 'vue' 149 | import './style.css'; 150 | import App from './App.vue'; 151 | 152 | const app = createApp(App); 153 | app.mount('#app'); 154 | `}, 155 | ); 156 | break; 157 | case JS_LIBRARIES[2]: 158 | jsFiles.push('@angular/core@latest'); 159 | extraFiles.push({ 160 | path: 'src/index.controller.js', 161 | content: `(function () { angular.module('app', [])})` 162 | }); 163 | break; 164 | case JS_LIBRARIES[3]: 165 | jsFiles.push('svelte@latest'); 166 | break; 167 | case JS_LIBRARIES[4]: 168 | jsFiles.push('jquery@latest'); 169 | extraFiles.push({ 170 | path: 'src/main.js', 171 | content: `$(function(){})` 172 | }); 173 | break; 174 | } 175 | } 176 | } else { 177 | 178 | const cdnAnswers = await inquirer.prompt([ 179 | { 180 | type: 'list', 181 | name: 'jsFramework', 182 | message: 'Select a JavaScript framework/library (CDN):', 183 | choices: JS_LIBRARIES 184 | }, 185 | { 186 | type: 'list', 187 | name: 'cssFramework', 188 | message: 'Select a CSS framework/library (CDN):', 189 | choices: CSS_LIBRARIES //'Tailwind', 'Bootstrap', 'Bulma'... 190 | } 191 | ]); 192 | 193 | switch (cdnAnswers.jsFramework) { 194 | case JS_LIBRARIES[0]: 195 | jsFiles.push('https://unpkg.com/react@latest/umd/react.production.min.js'); 196 | jsFiles.push('https://unpkg.com/react-dom@latest/umd/react-dom.production.min.js'); 197 | break; 198 | case JS_LIBRARIES[1]: 199 | jsFiles.push('https://unpkg.com/vue@latest/dist/vue.global.js'); 200 | break; 201 | case JS_LIBRARIES[2]: 202 | jsFiles.push('https://unpkg.com/@angular/core@latest/bundles/core.umd.js'); 203 | break; 204 | case JS_LIBRARIES[3]: 205 | jsFiles.push('https://unpkg.com/svelte@latest/compiled/svelte.js'); 206 | break; 207 | case JS_LIBRARIES[4]: 208 | jsFiles.push('https://code.jquery.com/jquery-latest.min.js'); 209 | break; 210 | } 211 | 212 | switch (cdnAnswers.cssFramework) { 213 | case CSS_LIBRARIES[0]: 214 | cssFiles.push('https://cdn.jsdelivr.net/npm/bootstrap@latest/dist/css/bootstrap.min.css'); 215 | break; 216 | case CSS_LIBRARIES[1]: 217 | cssFiles.push('https://cdn.jsdelivr.net/npm/bulma@latest/css/bulma.min.css'); 218 | break; 219 | case CSS_LIBRARIES[2]: 220 | cssFiles.push('https://cdn.tailwindcss.com'); 221 | break; 222 | } 223 | } 224 | 225 | 226 | const spinner = ora('Creating Puter app...').start(); 227 | 228 | try { 229 | const useBundler = answers.useBundler === 'Yes'; 230 | // Create basic app structure 231 | await createAppStructure(answers.name, useBundler, bundlerAnswers, frameworkAnswers, jsFiles, jsDevFiles, cssFiles, extraFiles); 232 | spinner.succeed(chalk.green('Successfully created Puter app!')); 233 | 234 | console.log('\nNext steps:'); 235 | console.log(chalk.cyan('1. cd'), answers.name); 236 | if (useBundler) { 237 | console.log(chalk.cyan('2. npm install')); 238 | console.log(chalk.cyan('3. npm start')); 239 | } else { 240 | console.log(chalk.cyan('2. Open index.html in your browser')); 241 | } 242 | } catch (error) { 243 | spinner.fail(chalk.red('Failed to create app')); 244 | console.error(error); 245 | } 246 | } 247 | 248 | async function createAppStructure(name, useBundler, bundlerAnswers, frameworkAnswers, jsFiles, jsDevFiles, cssFiles, extraFiles) { 249 | // Create project directory 250 | await fs.mkdir(name, { recursive: true }); 251 | 252 | // Generate default home page 253 | const homePage = useBundler?getDefaultHomePage(name): getDefaultHomePage(name, jsFiles, cssFiles); 254 | 255 | // Create basic files 256 | const files = { 257 | '.env': `APP_NAME=${name}\nPUTER_API_KEY=`, 258 | 'index.html': homePage, 259 | 'styles.css': `body { 260 | font-family: 'Segoe UI', Roboto, sans-serif; 261 | margin: 0 auto; 262 | padding: 10px; 263 | }`, 264 | 'app.js': `// Initialize Puter app 265 | console.log('Puter app initialized!');`, 266 | 'README.md': `# ${name}\n\nA Puter app created with puter-cli` 267 | }; 268 | 269 | for (const [filename, content] of Object.entries(files)) { 270 | await fs.writeFile(path.join(name, filename), content); 271 | } 272 | 273 | // If using a bundler, create a package.json 274 | // if (jsFiles.some(file => !file.startsWith('http'))) { 275 | if (useBundler) { 276 | 277 | const useFullStackFramework = bundlerAnswers.frameworkType === 'Full-stack framework'; 278 | const bundler = bundlerAnswers.bundler.toString().toLowerCase(); 279 | const framework = useFullStackFramework?frameworkAnswers.framework.toLowerCase():null; 280 | 281 | const scripts = { 282 | start: `${useFullStackFramework?`${framework} dev`:bundler} dev`, 283 | build: `${useFullStackFramework?`${framework} build`:bundler} build`, 284 | }; 285 | 286 | const packageJson = { 287 | name: name, 288 | version: '1.0.0', 289 | type: 'module', 290 | scripts, 291 | dependencies: {}, 292 | devDependencies: {} 293 | }; 294 | 295 | 296 | jsFiles.forEach(lib => { 297 | if (!lib.startsWith('http')) { 298 | packageJson.dependencies[lib.split('@')[0].toString().toLowerCase()] = lib.split('@')[1] || 'latest'; 299 | } 300 | }); 301 | 302 | jsDevFiles.forEach(lib => { 303 | packageJson.devDependencies[lib] = 'latest'; 304 | }); 305 | 306 | packageJson.devDependencies[bundler] = 'latest'; 307 | 308 | await fs.writeFile(path.join(name, 'package.json'), JSON.stringify(packageJson, null, 2)); 309 | 310 | extraFiles.forEach(async (extraFile) => { 311 | const fullPath = path.join(name, extraFile.path); 312 | // Create directories recursively if they don't exist 313 | await fs.mkdir(path.dirname(fullPath), { recursive: true }); 314 | await fs.writeFile(fullPath, extraFile.content); 315 | }); 316 | 317 | } 318 | } -------------------------------------------------------------------------------- /src/commands/shell.js: -------------------------------------------------------------------------------- 1 | import readline from 'node:readline'; 2 | import chalk from 'chalk'; 3 | import Conf from 'conf'; 4 | import { execCommand, getPrompt } from '../executor.js'; 5 | import { getAuthToken, login } from './auth.js'; 6 | import { PROJECT_NAME } from '../commons.js'; 7 | import ErrorModule from '../modules/ErrorModule.js'; 8 | import putility from '@heyputer/putility'; 9 | 10 | const config = new Conf({ projectName: PROJECT_NAME }); 11 | 12 | export const rl = readline.createInterface({ 13 | input: process.stdin, 14 | output: process.stdout, 15 | prompt: null 16 | }); 17 | 18 | /** 19 | * Update the current shell prompt 20 | */ 21 | export function updatePrompt(currentPath) { 22 | config.set('cwd', currentPath); 23 | rl.setPrompt(getPrompt()); 24 | } 25 | 26 | /** 27 | * Start the interactive shell 28 | */ 29 | export async function startShell() { 30 | if (!getAuthToken()) { 31 | console.log(chalk.cyan('Please login first (or use CTRL+C to exit):')); 32 | await login(); 33 | console.log(chalk.green(`Now just type: ${chalk.cyan('puter')} to begin.`)); 34 | process.exit(0); 35 | } 36 | 37 | const modules = [ 38 | ErrorModule, 39 | ]; 40 | 41 | const context = new putility.libs.context.Context({ 42 | events: new putility.libs.event.Emitter(), 43 | }); 44 | 45 | for ( const module of modules ) module({ context }); 46 | 47 | try { 48 | console.log(chalk.green('Welcome to Puter-CLI! Type "help" for available commands.')); 49 | rl.setPrompt(getPrompt()); 50 | rl.prompt(); 51 | 52 | rl.on('line', async (line) => { 53 | const trimmedLine = line.trim(); 54 | if (trimmedLine) { 55 | try { 56 | await execCommand(context, trimmedLine); 57 | } catch (error) { 58 | console.error(chalk.red(error.message)); 59 | } 60 | } 61 | rl.prompt(); 62 | }).on('close', () => { 63 | console.log(chalk.yellow('\nGoodbye!')); 64 | process.exit(0); 65 | }); 66 | } catch (error) { 67 | console.error(chalk.red('Error starting shell:', error)); 68 | } 69 | } -------------------------------------------------------------------------------- /src/commands/sites.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import fetch from 'node-fetch'; 3 | import Table from 'cli-table3'; 4 | import { getCurrentUserName, getCurrentDirectory } from './auth.js'; 5 | import { API_BASE, getHeaders, generateAppName, resolvePath, isValidAppName } from '../commons.js'; 6 | import { displayNonNullValues, formatDate, isValidAppUuid } from '../utils.js'; 7 | import { getSubdomains, createSubdomain, deleteSubdomain } from './subdomains.js'; 8 | import { ErrorAPI } from '../modules/ErrorModule.js'; 9 | 10 | 11 | /** 12 | * Listing subdomains 13 | */ 14 | export async function listSites(args = {}, context) { 15 | try { 16 | const data = await getSubdomains(args); 17 | 18 | if (!data.success || !Array.isArray(data.result)) { 19 | throw new Error('Failed to fetch subdomains'); 20 | } 21 | 22 | // Create table instance 23 | const table = new Table({ 24 | head: [ 25 | chalk.cyan('#'), 26 | chalk.cyan('UID'), 27 | chalk.cyan('Subdomain'), 28 | chalk.cyan('Created'), 29 | chalk.cyan('Protected'), 30 | // chalk.cyan('Owner'), 31 | chalk.cyan('Directory') 32 | ], 33 | wordWrap: false 34 | }); 35 | 36 | // Format and add data to table 37 | let i = 0; 38 | data.result.forEach(domain => { 39 | let appDir = domain?.root_dir?.path.split('/').pop().split('-'); 40 | table.push([ 41 | i++, 42 | domain.uid, 43 | chalk.green(`${chalk.dim(domain.subdomain)}.puter.site`), 44 | formatDate(domain.created_at).split(',')[0], 45 | domain.protected ? chalk.red('Yes') : chalk.green('No'), 46 | // domain.owner['username'], 47 | appDir && (isValidAppUuid(appDir.join('-'))?`${appDir[0]}-...-${appDir.slice(-1)}`:appDir.join('-')) 48 | ]); 49 | }); 50 | 51 | // Print table 52 | if (data.result.length === 0) { 53 | console.log(chalk.yellow('No subdomains found')); 54 | } else { 55 | console.log(chalk.bold('\nYour Sites:')); 56 | console.log(table.toString()); 57 | console.log(chalk.dim(`Total Sites: ${data.result.length}`)); 58 | } 59 | 60 | } catch (error) { 61 | context.events.emit('error', { error }); 62 | console.error(chalk.red('Error listing sites:'), error.message); 63 | throw error; 64 | } 65 | } 66 | 67 | /** 68 | * Get Site info 69 | * @param {any[]} args Array of site uuid 70 | */ 71 | export async function infoSite(args = []) { 72 | if (args.length < 1){ 73 | console.log(chalk.red('Usage: site ')); 74 | return; 75 | } 76 | for (const subdomainId of args) 77 | try { 78 | const response = await fetch(`${API_BASE}/drivers/call`, { 79 | method: 'POST', 80 | headers: getHeaders(), 81 | body: JSON.stringify({ 82 | interface: 'puter-subdomains', 83 | method: 'read', 84 | args: { uid: subdomainId } 85 | }) 86 | }); 87 | 88 | if (!response.ok) { 89 | throw new Error('Failed to fetch subdomains.'); 90 | } 91 | const data = await response.json(); 92 | if (!data.success || !data.result) { 93 | throw new Error(`Failed to get site info: ${data.error?.message}`); 94 | } 95 | displayNonNullValues(data.result); 96 | } catch (error) { 97 | console.error(chalk.red('Error getting site info:'), error.message); 98 | } 99 | } 100 | 101 | /** 102 | * Delete hosted web site 103 | * @param {any[]} args Array of site uuid 104 | */ 105 | export async function deleteSite(args = []) { 106 | if (args.length < 1){ 107 | console.log(chalk.red('Usage: site:delete ')); 108 | return false; 109 | } 110 | for (const uuid of args) 111 | try { 112 | // The uuid must be prefixed with: 'subdomainObj-' 113 | const response = await fetch(`${API_BASE}/delete-site`, { 114 | headers: getHeaders(), 115 | method: 'POST', 116 | body: JSON.stringify({ 117 | site_uuid: uuid 118 | }) 119 | }); 120 | 121 | if (!response.ok) { 122 | throw new Error(`Failed to delete site (Status: ${response.status})`); 123 | } 124 | 125 | const data = await response.json(); 126 | if (Object.keys(data).length==0) { 127 | console.log(chalk.green(`Site ID: "${uuid}" has been deleted.`)); 128 | return; 129 | } 130 | 131 | console.log(chalk.yellow(`Site ID: "${uuid}" should be deleted.`)); 132 | } catch (error) { 133 | console.error(chalk.red('Error deleting site:'), error.message); 134 | return false; 135 | } 136 | return true; 137 | } 138 | 139 | /** 140 | * Create a static web app from the current directory to Puter cloud. 141 | * @param {string[]} args - Command-line arguments (e.g., [name, --subdomain=]). 142 | */ 143 | export async function createSite(args = []) { 144 | if (args.length < 1 || !isValidAppName(args[0])) { 145 | console.log(chalk.red('Usage: site:create [] [--subdomain=]')); 146 | console.log(chalk.yellow('Example: site:create mysite')); 147 | console.log(chalk.yellow('Example: site:create mysite ./mysite')); 148 | console.log(chalk.yellow('Example: site:create mysite --subdomain=mysite')); 149 | return; 150 | } 151 | 152 | const appName = args[0]; // Site name (required) 153 | const subdomainOption = args.find(arg => arg.toLocaleLowerCase().startsWith('--subdomain='))?.split('=')[1]; // Optional subdomain 154 | // Use the current directory as the root directory if none specified 155 | const remoteDir = resolvePath(getCurrentDirectory(), (args[1] && !args[1].startsWith('--'))?args[1]:'.'); 156 | 157 | console.log(chalk.dim(`Creating site ${chalk.green(appName)} from: ${chalk.green(remoteDir)}...\n`)); 158 | try { 159 | // Step 1: Determine the subdomain 160 | let subdomain; 161 | if (subdomainOption) { 162 | subdomain = subdomainOption; // Use the provided subdomain 163 | } else { 164 | subdomain = appName; // Default to the app name as the subdomain 165 | } 166 | 167 | // Step 2: Check if the subdomain already exists 168 | const data = await getSubdomains(); 169 | if (!data.success || !Array.isArray(data.result)) { 170 | throw new Error('Failed to fetch subdomains'); 171 | } 172 | 173 | const subdomains = data.result; 174 | const subdomainObj = subdomains.find(sd => sd.subdomain === subdomain); 175 | if (subdomainObj) { 176 | console.error(chalk.cyan(`The subdomain "${subdomain}" is already in use and owned by: "${subdomainObj.owner['username']}"`)); 177 | if (subdomainObj.owner['username'] === getCurrentUserName()){ 178 | console.log(chalk.green(`It's yours, and linked to: ${subdomainObj.root_dir?.path}`)); 179 | if (subdomainObj.root_dir?.path === remoteDir){ 180 | console.log(chalk.cyan(`Which is already the selected directory, and created at:`)); 181 | console.log(chalk.green(`https://${subdomain}.puter.site`)); 182 | return; 183 | } else { 184 | console.log(chalk.yellow(`However, It's linked to different directory at: ${subdomainObj.root_dir?.path}`)); 185 | console.log(chalk.cyan(`We'll try to unlink this subdomain from that directory...`)); 186 | const result = await deleteSubdomain([subdomainObj?.uid]); 187 | if (result) { 188 | console.log(chalk.green('Looks like this subdomain is free again, please try again.')); 189 | return; 190 | } else { 191 | console.log(chalk.red('Could not release this subdomain.')); 192 | } 193 | } 194 | } 195 | } 196 | // else { 197 | // console.log(chalk.yellow(`The subdomain: "${subdomain}" is already taken, so let's generate a new random one:`)); 198 | // subdomain = generateAppName(); // Generate a random subdomain 199 | // console.log(chalk.cyan(`New generated subdomain: "${subdomain}" will be used.`)); 200 | // } 201 | 202 | // Use the chosen "subdomain" 203 | console.log(chalk.cyan(`New generated subdomain: "${subdomain}" will be used if its not already in use.`)); 204 | 205 | // Step 3: Host the current directory under the subdomain 206 | console.log(chalk.cyan(`Hosting app "${appName}" under subdomain "${subdomain}"...`)); 207 | const site = await createSubdomain(subdomain, remoteDir); 208 | if (!site){ 209 | console.error(chalk.red(`Failed to create subdomain: "${chalk.red(subdomain)}"`)); 210 | return; 211 | } 212 | 213 | console.log(chalk.green(`App ${chalk.dim(appName)} created successfully and accessible at:`)); 214 | console.log(chalk.cyan(`https://${site.subdomain}.puter.site`)); 215 | } catch (error) { 216 | console.error(chalk.red('Failed to create site.')); 217 | console.error(chalk.red(`Error: ${error.message}`)); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/commands/subdomains.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import fetch from 'node-fetch'; 3 | import { API_BASE, getHeaders } from '../commons.js'; 4 | 5 | /** 6 | * Get list of subdomains. 7 | * @param {Object} args - Options for the query. 8 | * @returns {Array} - Array of subdomains. 9 | */ 10 | export async function getSubdomains(args = {}) { 11 | const response = await fetch(`${API_BASE}/drivers/call`, { 12 | method: 'POST', 13 | headers: getHeaders(), 14 | body: JSON.stringify({ 15 | interface: 'puter-subdomains', 16 | method: 'select', 17 | args: args 18 | }) 19 | }); 20 | 21 | if (!response.ok) { 22 | throw new Error('Failed to fetch subdomains.'); 23 | } 24 | return await response.json(); 25 | } 26 | 27 | /** 28 | * Delete a subdomain by id 29 | * @param {Array} subdomain IDs 30 | * @return {boolean} Result of the operation 31 | */ 32 | export async function deleteSubdomain(args = []) { 33 | if (args.length < 1){ 34 | console.log(chalk.red('Usage: domain:delete ')); 35 | return false; 36 | } 37 | const subdomains = args; 38 | for (const subdomainId of subdomains) 39 | try { 40 | const response = await fetch(`${API_BASE}/drivers/call`, { 41 | headers: getHeaders(), 42 | method: 'POST', 43 | body: JSON.stringify({ 44 | interface: 'puter-subdomains', 45 | method: 'delete', 46 | args: { 47 | id: { subdomain: subdomainId } 48 | } 49 | }) 50 | }); 51 | 52 | const data = await response.json(); 53 | if (!data.success) { 54 | if (data.error?.code === 'entity_not_found') { 55 | console.log(chalk.red(`Subdomain ID: "${subdomainId}" not found`)); 56 | return false; 57 | } 58 | console.log(chalk.red(`Failed to delete subdomain: ${data.error?.message}`)); 59 | return false; 60 | } 61 | console.log(chalk.green('Subdomain deleted successfully')); 62 | } catch (error) { 63 | console.error(chalk.red('Error deleting subdomain:'), error.message); 64 | } 65 | return true; 66 | } 67 | 68 | /** 69 | * Create a new subdomain into remote directory 70 | * @param {string} subdomain - Subdomain name. 71 | * @param {string} remoteDir - Remote directory path. 72 | * @returns {Object} - Hosting details (e.g., subdomain). 73 | */ 74 | export async function createSubdomain(subdomain, remoteDir) { 75 | const response = await fetch(`${API_BASE}/drivers/call`, { 76 | method: 'POST', 77 | headers: getHeaders(), 78 | body: JSON.stringify({ 79 | interface: 'puter-subdomains', 80 | method: 'create', 81 | args: { 82 | object: { 83 | subdomain: subdomain, 84 | root_dir: remoteDir 85 | } 86 | } 87 | }) 88 | }); 89 | 90 | if (!response.ok) { 91 | throw new Error('Failed to host directory.'); 92 | } 93 | const data = await response.json(); 94 | if (!data.success || !data.result) { 95 | if (data.error?.code === 'already_in_use') { 96 | // data.error?.status===409 97 | console.log(chalk.yellow(`Subdomain already taken!\nMessage: ${data?.error?.message}`)); 98 | return false; 99 | } 100 | console.log(chalk.red(`Error when creating "${subdomain}".\nError: ${data?.error?.message}\nCode: ${data.error?.code}`)); 101 | return false; 102 | } 103 | return data.result; 104 | } 105 | -------------------------------------------------------------------------------- /src/commons.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { getAuthToken } from './commands/auth.js'; 3 | import { formatSize } from './utils.js'; 4 | import { readFile } from 'fs/promises'; 5 | import { fileURLToPath } from 'url'; 6 | import { dirname, join } from 'path'; 7 | import dotenv from 'dotenv'; 8 | 9 | dotenv.config(); 10 | 11 | export const PROJECT_NAME = 'puter-cli'; 12 | // If you haven't defined your own values in .env file, we'll assume you're running Puter on a local instance: 13 | export const API_BASE = process.env.PUTER_API_BASE || 'https://api.puter.com'; 14 | export const BASE_URL = process.env.PUTER_BASE_URL || 'https://puter.com'; 15 | 16 | /** 17 | * Get headers with the correct Content-Type for multipart form data. 18 | * @param {string} contentType - The "Content-Type" argument for the header ('application/json' is the default) 19 | * Use the multipart form data for upload a file. 20 | * @returns {Object} The headers object. 21 | */ 22 | export function getHeaders(contentType = 'application/json') { 23 | return { 24 | 'Accept': '*/*', 25 | 'Accept-Language': 'en-US,en;q=0.9', 26 | 'Authorization': `Bearer ${getAuthToken()}`, 27 | 'Connection': 'keep-alive', 28 | // 'Host': 'api.puter.com', 29 | 'Content-Type': contentType, 30 | 'Origin': `${BASE_URL}`, 31 | 'Referer': `${BASE_URL}/`, 32 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' 33 | } 34 | } 35 | 36 | /** 37 | * Generate a random app name 38 | * @returns a random app name or null if it fails 39 | * @see: [randName](https://github.com/HeyPuter/puter/blob/06a67a3b223a6cbd7ec2e16853b6d2304f621a88/src/puter-js/src/index.js#L389) 40 | */ 41 | export function generateAppName(separateWith = '-'){ 42 | console.log(chalk.cyan('Generating random app name...')); 43 | try { 44 | const first_adj = ['helpful','sensible', 'loyal', 'honest', 'clever', 'capable','calm', 'smart', 'genius', 'bright', 'charming', 'creative', 'diligent', 'elegant', 'fancy', 45 | 'colorful', 'avid', 'active', 'gentle', 'happy', 'intelligent', 'jolly', 'kind', 'lively', 'merry', 'nice', 'optimistic', 'polite', 46 | 'quiet', 'relaxed', 'silly', 'victorious', 'witty', 'young', 'zealous', 'strong', 'brave', 'agile', 'bold']; 47 | 48 | const nouns = ['street', 'roof', 'floor', 'tv', 'idea', 'morning', 'game', 'wheel', 'shoe', 'bag', 'clock', 'pencil', 'pen', 49 | 'magnet', 'chair', 'table', 'house', 'dog', 'room', 'book', 'car', 'cat', 'tree', 50 | 'flower', 'bird', 'fish', 'sun', 'moon', 'star', 'cloud', 'rain', 'snow', 'wind', 'mountain', 51 | 'river', 'lake', 'sea', 'ocean', 'island', 'bridge', 'road', 'train', 'plane', 'ship', 'bicycle', 52 | 'horse', 'elephant', 'lion', 'tiger', 'bear', 'zebra', 'giraffe', 'monkey', 'snake', 'rabbit', 'duck', 53 | 'goose', 'penguin', 'frog', 'crab', 'shrimp', 'whale', 'octopus', 'spider', 'ant', 'bee', 'butterfly', 'dragonfly', 54 | 'ladybug', 'snail', 'camel', 'kangaroo', 'koala', 'panda', 'piglet', 'sheep', 'wolf', 'fox', 'deer', 'mouse', 'seal', 55 | 'chicken', 'cow', 'dinosaur', 'puppy', 'kitten', 'circle', 'square', 'garden', 'otter', 'bunny', 'meerkat', 'harp'] 56 | 57 | // return a random combination of first_adj + noun + number (between 0 and 9999) 58 | // e.g. clever-idea-123 59 | const appName = first_adj[Math.floor(Math.random() * first_adj.length)] + separateWith + nouns[Math.floor(Math.random() * nouns.length)] + separateWith + Math.floor(Math.random() * 10000); 60 | console.log(chalk.green(`AppName: "${appName}"`)); 61 | return appName; 62 | } catch (error) { 63 | console.error(`Error: ${error.message}`); 64 | return null; 65 | } 66 | } 67 | 68 | /** 69 | * Display data in a structured format 70 | * @param {Array} data - The data to display 71 | * @param {Object} options - Display options 72 | * @param {Array} options.headers - Headers for the table 73 | * @param {Array} options.columns - Columns to display 74 | * @param {number} options.columnWidth - Width of each column 75 | */ 76 | export function displayTable(data, options = {}) { 77 | const { headers = [], columns = [], columnWidth = 20 } = options; 78 | 79 | // Create the header row 80 | const headerRow = headers.map(header => chalk.cyan(header.padEnd(columnWidth))).join(' | '); 81 | console.log(headerRow); 82 | console.log(chalk.dim('-'.repeat(headerRow.length))); 83 | 84 | // Create and display each row of data 85 | data.forEach(item => { 86 | const row = columns.map(col => { 87 | const value = item[col] || 'N/A'; 88 | return value.toString().padEnd(columnWidth); 89 | }).join(' | '); 90 | console.log(row); 91 | }); 92 | } 93 | 94 | /** 95 | * Display structured ouput of disk usage informations 96 | */ 97 | export function showDiskSpaceUsage(data) { 98 | const freeSpace = parseInt(data.capacity) - parseInt(data.used); 99 | const usagePercentage = (parseInt(data.used) / parseInt(data.capacity)) * 100; 100 | console.log(chalk.cyan('Disk Usage Information:')); 101 | console.log(chalk.dim('----------------------------------------')); 102 | console.log(chalk.cyan(`Total Capacity: `) + chalk.white(formatSize(data.capacity))); 103 | console.log(chalk.cyan(`Used Space: `) + chalk.white(formatSize(data.used))); 104 | console.log(chalk.cyan(`Free Space: `) + chalk.white(formatSize(freeSpace))); 105 | // format the usagePercentage with 2 decimal floating point value: 106 | console.log(chalk.cyan(`Usage Percentage: `) + chalk.white(`${usagePercentage.toFixed(2)}%`)); 107 | console.log(chalk.dim('----------------------------------------')); 108 | } 109 | 110 | /** 111 | * Resolve a relative path to an absolute path 112 | * @param {string} currentPath - The current working directory 113 | * @param {string} relativePath - The relative path to resolve 114 | * @returns {string} The resolved absolute path 115 | */ 116 | export function resolvePath(currentPath, relativePath) { 117 | // Normalize the current path (remove trailing slashes) 118 | currentPath = currentPath.replace(/\/+$/, ''); 119 | 120 | // Split the relative path into parts 121 | const parts = relativePath.split('/').filter(p => p); // Remove empty parts 122 | 123 | // Handle each part of the relative path 124 | for (const part of parts) { 125 | if (part === '..') { 126 | // Move one level up 127 | const currentParts = currentPath.split('/').filter(p => p); 128 | if (currentParts.length > 0) { 129 | currentParts.pop(); // Remove the last part 130 | } 131 | currentPath = '/' + currentParts.join('/'); 132 | } else if (part === '.') { 133 | // Stay in the current directory (no change) 134 | continue; 135 | } else { 136 | // Move into a subdirectory 137 | currentPath += `/${part}`; 138 | } 139 | } 140 | 141 | // Normalize the final path (remove duplicate slashes) 142 | currentPath = currentPath.replace(/\/+/g, '/'); 143 | 144 | // Ensure the path ends with a slash if it's the root 145 | if (currentPath === '') { 146 | currentPath = '/'; 147 | } 148 | 149 | return currentPath; 150 | } 151 | 152 | /** 153 | * Checks if a given string is a valid app name. 154 | * The name must: 155 | * - Not be '.' or '..' 156 | * - Not contain path separators ('/' or '\\') 157 | * - Not contain wildcard characters ('*') 158 | * - (Optional) Contain only allowed characters (letters, numbers, spaces, underscores, hyphens) 159 | * 160 | * @param {string} name - The app name to validate. 161 | * @returns {boolean} - Returns true if valid, false otherwise. 162 | */ 163 | export function isValidAppName(name) { 164 | // Ensure the name is a non-empty string 165 | if (typeof name !== 'string' || name.trim().length === 0) { 166 | return false; 167 | } 168 | 169 | // Trim whitespace from both ends 170 | const trimmedName = name.trim(); 171 | 172 | // Reject reserved names 173 | if (trimmedName === '.' || trimmedName === '..') { 174 | return false; 175 | } 176 | 177 | // Regex patterns for invalid characters 178 | const invalidPattern = /[\/\\*]/; // Disallow /, \, and * 179 | 180 | if (invalidPattern.test(trimmedName)) { 181 | return false; 182 | } 183 | 184 | // Optional: Define allowed characters pattern 185 | // Uncomment the following lines if you want to enforce allowed characters 186 | /* 187 | const allowedPattern = /^[A-Za-z0-9 _-]+$/; 188 | if (!allowedPattern.test(trimmedName)) { 189 | return false; 190 | } 191 | */ 192 | 193 | // All checks passed 194 | return true; 195 | } 196 | 197 | /** 198 | * Generate the default home page for a new web application 199 | * @param {string} appName The name of the web application 200 | * @returns HTML template of the app 201 | */ 202 | export function getDefaultHomePage(appName, jsFiles = [], cssFiles= []) { 203 | const defaultIndexContent = ` 204 | 205 | 206 | 207 | 208 | ${appName} 209 | ${cssFiles.map(css => ``).join('\n ')} 210 | 262 | 263 | 264 |
265 |

🚀 Welcome to ${appName}!

266 | 267 |

This is your new website powered by Puter. You can start customizing it right away!

268 | 269 |
270 | Quick Tip: Replace this content with your own by editing the index.html file. 271 |
272 | 273 |

🌟 Getting Started

274 | 275 |

Here's a simple example using Puter.js:

276 | 277 |
278 | <script src="https://js.puter.com/v2/"></script> 279 | <script> 280 | // Create a new file in the cloud 281 | puter.fs.write('hello.txt', 'Hello, Puter!') 282 | .then(file => console.log(\`File created at: \${file.path}\`)); 283 | </script> 284 |
285 | 286 |

💡 Key Features

287 |
    288 |
  • Cloud Storage
  • 289 |
  • AI Services (GPT-4, DALL-E)
  • 290 |
  • Static Website Hosting
  • 291 |
  • Key-Value Store
  • 292 |
  • Authentication
  • 293 |
294 | 295 | 300 |
301 | 302 |
303 | © 2025 ${appName}. All rights reserved. 304 |
305 | 306 |
307 | ${jsFiles.map(js => 308 | `` 309 | ).join('\n ')} 310 | 311 | `; 312 | 313 | return defaultIndexContent; 314 | } 315 | 316 | 317 | /** 318 | * Read latest package from package file 319 | */ 320 | export async function getVersionFromPackage() { 321 | try { 322 | const __filename = fileURLToPath(import.meta.url); 323 | const __dirname = dirname(__filename); 324 | 325 | // First try parent directory (dev mode) 326 | try { 327 | const devPackage = JSON.parse( 328 | await readFile(join(__dirname, '..', 'package.json'), 'utf8') 329 | ); 330 | return devPackage.version; 331 | } catch (devError) { 332 | // Fallback to current directory (production) 333 | const prodPackage = JSON.parse( 334 | await readFile(join(__dirname, 'package.json'), 'utf8') 335 | ); 336 | return prodPackage.version; 337 | } 338 | } catch (error) { 339 | console.error(`Error fetching latest version:`, error.message); 340 | return null; 341 | } 342 | } 343 | 344 | /** 345 | * Get latest package info from npm registery 346 | */ 347 | export async function getLatestVersion(packageName) { 348 | try { 349 | const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`); 350 | let data = await response.json(); 351 | const currentVersion = await getVersionFromPackage(); 352 | if (data.version !== currentVersion){ 353 | return `v${currentVersion} (latest: ${data.version})`; 354 | } 355 | return `v${currentVersion}`; 356 | } catch (error) { 357 | console.error(`ERROR: ${error.message}`); 358 | return ""; 359 | } 360 | } -------------------------------------------------------------------------------- /src/crypto.js: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | const randomUUID = () => { 4 | return uuidv4(); 5 | }; 6 | 7 | export default { 8 | randomUUID 9 | }; -------------------------------------------------------------------------------- /src/executor.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import Conf from 'conf'; 3 | import { listApps, appInfo, createApp, updateApp, deleteApp } from './commands/apps.js'; 4 | import { listSites, createSite, deleteSite, infoSite } from './commands/sites.js'; 5 | import { listFiles, makeDirectory, renameFileOrDirectory, 6 | removeFileOrDirectory, emptyTrash, changeDirectory, showCwd, 7 | getInfo, getDiskUsage, createFile, readFile, uploadFile, 8 | downloadFile, copyFile, syncDirectory, editFile } from './commands/files.js'; 9 | import { getUserInfo, getUsageInfo } from './commands/auth.js'; 10 | import { PROJECT_NAME, API_BASE, getHeaders } from './commons.js'; 11 | import inquirer from 'inquirer'; 12 | import { exec } from 'node:child_process'; 13 | import { parseArgs, getSystemEditor } from './utils.js'; 14 | import { rl } from './commands/shell.js'; 15 | import { ErrorAPI } from './modules/ErrorModule.js'; 16 | 17 | const config = new Conf({ projectName: PROJECT_NAME }); 18 | 19 | // History of commands 20 | const commandHistory = []; 21 | 22 | /** 23 | * Update the prompt function 24 | * @returns The current prompt 25 | */ 26 | export function getPrompt() { 27 | return chalk.cyan(`puter@${config.get('cwd').slice(1)}> `); 28 | } 29 | 30 | const commands = { 31 | help: showHelp, 32 | exit: () => process.exit(0), 33 | logout: async () => { 34 | await import('./commands/auth.js').then(m => m.logout()); 35 | process.exit(0); 36 | }, 37 | whoami: getUserInfo, 38 | stat: getInfo, 39 | apps: async (args) => { 40 | await listApps({ 41 | statsPeriod: args[0] || 'all' 42 | }); 43 | }, 44 | app: appInfo, 45 | history: async (args) => { 46 | const lineNumber = parseInt(args[0]); 47 | 48 | if (isNaN(lineNumber)) { 49 | // Display full history 50 | commandHistory.forEach((command, index) => { 51 | console.log(chalk.cyan(`${index + 1}: ${command}`)); 52 | }); 53 | } else { 54 | // Copy the command at the specified line number 55 | if (lineNumber < 1 || lineNumber > commandHistory.length) { 56 | console.error(chalk.red(`Invalid line number. History has ${commandHistory.length} entries.`)); 57 | return; 58 | } 59 | 60 | const commandToCopy = commandHistory[lineNumber - 1]; 61 | // Simulate typing the command in the shell 62 | rl.write(commandToCopy); 63 | } 64 | }, 65 | 'last-error': async (_, context) => { 66 | context[ErrorAPI].showLast(); 67 | }, 68 | 'app:create': async (rawArgs) => { 69 | try { 70 | const args = parseArgs(rawArgs.join(' ')); 71 | // Consider using explicit argument definition if necessary 72 | // const args = parseArgs(rawArgs.join(' '), {string: ['description', 'url'], 73 | // alias: { d: 'description', u: 'url', }, 74 | // }); 75 | 76 | if (!args.length < 1) { 77 | console.log(chalk.red('Usage: app:create [directory] [--description="My App Description"] [--url=app-url]')); 78 | return; 79 | } 80 | 81 | await createApp({ 82 | name: args._[0], 83 | directory: args._[1] || '', 84 | description: args.description || '', 85 | url: args.url || 'https://dev-center.puter.com/coming-soon.html' 86 | }); 87 | } catch (error) { 88 | console.error(chalk.red(error.message)); 89 | } 90 | }, 91 | 'app:update': async (args) => { 92 | if (args.length < 1) { 93 | console.log(chalk.red('Usage: app:update ')); 94 | return; 95 | } 96 | await updateApp(args); 97 | }, 98 | 'app:delete': async (rawArgs) => { 99 | const args = parseArgs(rawArgs.join(' '), { 100 | string: ['_'], 101 | boolean: ['f'], 102 | configuration: { 103 | 'populate--': true 104 | } 105 | }); 106 | if (args.length < 1) { 107 | console.log(chalk.red('Usage: app:delete ')); 108 | return; 109 | } 110 | const name = args._[0]; 111 | const force = !!args.f; 112 | 113 | if (!force){ 114 | const { confirm } = await inquirer.prompt([ 115 | { 116 | type: 'confirm', 117 | name: 'confirm', 118 | message: chalk.yellow(`Are you sure you want to delete "${name}"?`), 119 | default: false 120 | } 121 | ]); 122 | if (!confirm) { 123 | console.log(chalk.yellow('Operation cancelled.')); 124 | return false; 125 | } 126 | } 127 | await deleteApp(name); 128 | }, 129 | ls: listFiles, 130 | cd: async (args) => { 131 | await changeDirectory(args); 132 | }, 133 | pwd: showCwd, 134 | mkdir: makeDirectory, 135 | mv: renameFileOrDirectory, 136 | rm: removeFileOrDirectory, 137 | // rmdir: deleteFolder, // Not implemented in Puter API 138 | clean: emptyTrash, 139 | df: getDiskUsage, 140 | usage: getUsageInfo, 141 | cp: copyFile, 142 | touch: createFile, 143 | cat: readFile, 144 | push: uploadFile, 145 | pull: downloadFile, 146 | update: syncDirectory, 147 | edit: editFile, 148 | sites: listSites, 149 | site: infoSite, 150 | 'site:delete': deleteSite, 151 | 'site:create': createSite, 152 | }; 153 | 154 | /** 155 | * Execute a command 156 | * @param {string} input The command line input 157 | */ 158 | export async function execCommand(context, input) { 159 | const [cmd, ...args] = input.split(' '); 160 | 161 | 162 | // Add the command to history (skip the "history" command itself) 163 | if (cmd !== 'history') { 164 | commandHistory.push(input); 165 | } 166 | 167 | if (cmd === 'help') { 168 | // Handle help command 169 | const command = args[0]; 170 | showHelp(command); 171 | return; 172 | } 173 | if (cmd.startsWith('!')) { 174 | // Execute the command on the host machine 175 | const hostCommand = input.slice(1); // Remove the "!" 176 | exec(hostCommand, (error, stdout, stderr) => { 177 | if (error) { 178 | console.error(chalk.red(`Host Error: ${error.message}`)); 179 | return; 180 | } 181 | if (stderr) { 182 | console.error(chalk.red(stderr)); 183 | return; 184 | } 185 | console.log(stdout); 186 | console.log(chalk.green(`Press to return.`)); 187 | }); 188 | return; 189 | } 190 | if (commands[cmd]) { 191 | try { 192 | await commands[cmd](args, context); 193 | } catch (error) { 194 | console.error(chalk.red(`Error executing command: ${error.message}`)); 195 | } 196 | return; 197 | } 198 | 199 | if (!['Y', 'N'].includes(cmd.toUpperCase()[0])) { 200 | console.log(chalk.red(`Unknown command: ${cmd}`)); 201 | showHelp(); 202 | } 203 | } 204 | 205 | /** 206 | * Display help for a specific command or general help if no command is provided. 207 | * @param {string} [command] - The command to display help for. 208 | */ 209 | function showHelp(command) { 210 | // Consider using `program.helpInformation()` function for global "help" command... 211 | const commandHelp = { 212 | help: ` 213 | ${chalk.cyan('help [command]')} 214 | Display help for a specific command or show general help. 215 | Example: help ls 216 | `, 217 | exit: ` 218 | ${chalk.cyan('exit')} 219 | Exit the shell. 220 | `, 221 | logout: ` 222 | ${chalk.cyan('logout')} 223 | Logout from Puter account. 224 | `, 225 | whoami: ` 226 | ${chalk.cyan('whoami')} 227 | Show user information. 228 | `, 229 | stat: ` 230 | ${chalk.cyan('stat ')} 231 | Show file or directory information. 232 | Example: stat /path/to/file 233 | `, 234 | df: ` 235 | ${chalk.cyan('df')} 236 | Show disk usage information. 237 | `, 238 | usage: ` 239 | ${chalk.cyan('usage')} 240 | Show usage information. 241 | `, 242 | apps: ` 243 | ${chalk.cyan('apps [period]')} 244 | List all your apps. 245 | period: today, yesterday, 7d, 30d, this_month, last_month 246 | Example: apps today 247 | `, 248 | app: ` 249 | ${chalk.cyan('app ')} 250 | Get application information. 251 | Example: app myapp 252 | `, 253 | 'app:create': ` 254 | ${chalk.cyan('app:create [] [--description=""] [--url=]')} 255 | Create a new app. 256 | Example: app:create myapp https://myapp.puter.site 257 | `, 258 | 'app:update': ` 259 | ${chalk.cyan('app:update [dir]')} 260 | Update an app. 261 | Example: app:update myapp . 262 | `, 263 | 'app:delete': ` 264 | ${chalk.cyan('app:delete ')} 265 | Delete an app. 266 | Example: app:delete myapp 267 | `, 268 | ls: ` 269 | ${chalk.cyan('ls [dir]')} 270 | List files and directories. 271 | Example: ls /path/to/dir 272 | `, 273 | cd: ` 274 | ${chalk.cyan('cd [dir]')} 275 | Change the current working directory. 276 | Example: cd /path/to/dir 277 | `, 278 | pwd: ` 279 | ${chalk.cyan('pwd')} 280 | Print the current working directory. 281 | `, 282 | mkdir: ` 283 | ${chalk.cyan('mkdir ')} 284 | Create a new directory. 285 | Example: mkdir /path/to/newdir 286 | `, 287 | mv: ` 288 | ${chalk.cyan('mv ')} 289 | Move or rename a file or directory. 290 | Example: mv /path/to/src /path/to/dest 291 | `, 292 | rm: ` 293 | ${chalk.cyan('rm ')} 294 | Move a file or directory to the system's Trash. 295 | Example: rm /path/to/file 296 | `, 297 | clean: ` 298 | ${chalk.cyan('clean')} 299 | Empty the system's Trash. 300 | `, 301 | cp: ` 302 | ${chalk.cyan('cp ')} 303 | Copy files or directories. 304 | Example: cp /path/to/src /path/to/dest 305 | `, 306 | touch: ` 307 | ${chalk.cyan('touch ')} 308 | Create a new empty file. 309 | Example: touch /path/to/file 310 | `, 311 | cat: ` 312 | ${chalk.cyan('cat ')} 313 | Output file content to the console. 314 | Example: cat /path/to/file 315 | `, 316 | push: ` 317 | ${chalk.cyan('push ')} 318 | Upload file to Puter cloud. 319 | Example: push /path/to/file 320 | `, 321 | pull: ` 322 | ${chalk.cyan('pull ')} 323 | Download file from Puter cloud. 324 | Example: pull /path/to/file 325 | `, 326 | update: ` 327 | ${chalk.cyan('update [--delete] [-r]')} 328 | Sync local directory with remote cloud. 329 | Example: update /local/path /remote/path 330 | `, 331 | edit: ` 332 | ${chalk.cyan('edit ')} 333 | Edit a remote file using your local text editor. 334 | Example: edit /path/to/file 335 | 336 | System editor: ${chalk.green(getSystemEditor())} 337 | `, 338 | sites: ` 339 | ${chalk.cyan('sites')} 340 | List sites and subdomains. 341 | `, 342 | site: ` 343 | ${chalk.cyan('site ')} 344 | Get site information by UID. 345 | Example: site sd-123456 346 | `, 347 | 'site:delete': ` 348 | ${chalk.cyan('site:delete ')} 349 | Delete a site by UID. 350 | Example: site:delete sd-123456 351 | `, 352 | 'site:create': ` 353 | ${chalk.cyan('site:create [] [--subdomain=]')} 354 | Create a static website from directory. 355 | Example: site:create mywebsite /path/to/dir --subdomain=mywebsite 356 | `, 357 | '!': ` 358 | ${chalk.cyan('!')} 359 | Execute a command on the host machine. 360 | Example: !ls -la 361 | `, 362 | 'history [line]': ` 363 | ${chalk.cyan('history [line]')} 364 | Display history of commands or copy command by line number 365 | Example: history 2 366 | `, 367 | }; 368 | 369 | if (command && commandHelp[command]) { 370 | console.log(chalk.yellow(`\nHelp for command: ${chalk.cyan(command)}`)); 371 | console.log(commandHelp[command]); 372 | } else if (command) { 373 | console.log(chalk.red(`Unknown command: ${command}`)); 374 | console.log(chalk.yellow('Use "help" to see a list of available commands.')); 375 | } else { 376 | console.log(chalk.yellow('\nAvailable commands:')); 377 | for (const cmd in commandHelp) { 378 | console.log(chalk.cyan(cmd.padEnd(20)) + '- ' + commandHelp[cmd].split('\n')[2].trim()); 379 | } 380 | console.log(chalk.yellow('\nUse "help " for detailed help on a specific command.')); 381 | } 382 | } -------------------------------------------------------------------------------- /src/modules/ErrorModule.js: -------------------------------------------------------------------------------- 1 | const ERROR_BUFFER_LIMIT = 20; 2 | 3 | export const ErrorAPI = Symbol('ErrorAPI'); 4 | 5 | export default ({ context }) => { 6 | // State Variables 7 | const errors = []; 8 | 9 | context.events.on('error', (error) => { 10 | context[ErrorAPI].report(error); 11 | }); 12 | 13 | // Module Methods 14 | context[ErrorAPI] = { 15 | // Add an error to the error history 16 | report (error) { 17 | errors.push(error); 18 | if (errors.length > ERROR_BUFFER_LIMIT) { 19 | errors = errors.slice(errors.length - ERROR_BUFFER_LIMIT); 20 | } 21 | }, 22 | // Print the last error from the error history, 23 | // and remove it from the history 24 | showLast () { 25 | const err = errors.pop(); 26 | if (err) { 27 | console.error(err); 28 | } else { 29 | console.log('No errors to report'); 30 | } 31 | } 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import yargsParser from 'yargs-parser'; 3 | 4 | /** 5 | * Convert "2024-10-07T15:03:53.000Z" to "10/7/2024, 15:03:53" 6 | * @param {Date} value date value 7 | * @returns formatted date string 8 | */ 9 | export function formatDate(value) { 10 | const date = new Date(value); 11 | return date.toLocaleString("en-US", { 12 | year: "numeric", 13 | month: "2-digit", 14 | day: "2-digit", 15 | hour: "2-digit", 16 | minute: "2-digit", 17 | second: "2-digit", 18 | hour12: false, 19 | timeZone: 'UTC' 20 | }); 21 | } 22 | 23 | /** 24 | * Format timestamp to date or time 25 | * @param {number} timestamp value 26 | * @returns string 27 | */ 28 | export function formatDateTime(timestamp) { 29 | const date = new Date(timestamp * 1000); // Convert to milliseconds 30 | const now = new Date(); 31 | const diff = now - date; 32 | if (diff < 86400000) { // Less than 24 hours 33 | return date.toLocaleTimeString(); 34 | } else { 35 | return date.toLocaleDateString(); 36 | } 37 | } 38 | /** 39 | * Format file size in human readable format 40 | * @param {number} size File size value 41 | * @returns string formatted in human readable format 42 | */ 43 | export function formatSize(size) { 44 | if (size === null || size === undefined) return '0'; 45 | if (size === 0) return '0'; 46 | const units = ['B', 'KB', 'MB', 'GB', 'TB']; 47 | let unit = 0; 48 | while (size >= 1024 && unit < units.length - 1) { 49 | size /= 1024; 50 | unit++; 51 | } 52 | return `${size.toFixed(1)} ${units[unit]}`; 53 | } 54 | 55 | /** 56 | * Display non null values in formatted table 57 | * @param {Object} data Object to display 58 | * @returns null 59 | */ 60 | export function displayNonNullValues(data) { 61 | if (typeof data !== 'object' || data === null) { 62 | console.error("Invalid input: Input must be a non-null object."); 63 | return; 64 | } 65 | const tableData = []; 66 | function flattenObject(obj, parentKey = '') { 67 | for (const key in obj) { 68 | const value = obj[key]; 69 | const newKey = parentKey ? `${parentKey}.${key}` : key; 70 | if (value !== null) { 71 | if (typeof value === 'object') { 72 | flattenObject(value, newKey); 73 | } else { 74 | tableData.push({ key: newKey, value: value }); 75 | } 76 | } 77 | } 78 | } 79 | 80 | flattenObject(data); 81 | // Determine max key length for formatting 82 | const maxKeyLength = tableData.reduce((max, item) => Math.max(max, item.key.length), 0); 83 | // Format and output the table 84 | console.log(chalk.cyan('-'.repeat(maxKeyLength*3))); 85 | console.log(chalk.cyan(`| ${'Key'.padEnd(maxKeyLength)} | Value`)); 86 | console.log(chalk.cyan('-'.repeat(maxKeyLength*3))); 87 | tableData.forEach(item => { 88 | const key = item.key.padEnd(maxKeyLength); 89 | const value = String(item.value); 90 | console.log(chalk.green(`| ${chalk.dim(key)} | ${value}`)); 91 | }); 92 | console.log(chalk.cyan('-'.repeat(maxKeyLength*3))); 93 | console.log(chalk.cyan(`You have ${chalk.green(tableData.length)} key/value pair(s).`)); 94 | } 95 | 96 | /** 97 | * Parse command line arguments including quoted strings 98 | * @param {string} input Raw command line input 99 | * @returns {Object} Parsed arguments 100 | */ 101 | export function parseArgs(input, options = {}) { 102 | const result = yargsParser(input, options); 103 | return result; 104 | } 105 | 106 | /** 107 | * Checks if a given string is a valid UUID of any version 108 | * @param {string} uuid - The string to validate. 109 | * @returns {boolean} - True if the string is a valid UUID, false otherwise. 110 | */ 111 | export function isValidAppUuid (uuid) { 112 | return uuid.startsWith('app-') && is_valid_uuid4(uuid.slice(4)); 113 | } 114 | 115 | /** 116 | * Checks if a given string is a valid UUID version 4. 117 | * @param {string} uuid - The string to validate. 118 | * @returns {boolean} - True if the string is a valid UUID version 4, false otherwise. 119 | */ 120 | export function is_valid_uuid4 (uuid) { 121 | const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; 122 | return uuidV4Regex.test(uuid); 123 | } 124 | 125 | /** 126 | * Get system editor 127 | * @returns {string} - System editor 128 | * @example 129 | * getSystemEditor() 130 | * // => 'nano' 131 | */ 132 | export function getSystemEditor() { 133 | return process.env.EDITOR || process.env.VISUAL || 134 | (process.platform === 'win32' ? 'notepad' : 'vi') 135 | } -------------------------------------------------------------------------------- /tests/login.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { login, logout, getUserInfo, isAuthenticated, getAuthToken, getCurrentUserName, 3 | getCurrentDirectory, getUsageInfo } from '../src/commands/auth.js'; 4 | import inquirer from 'inquirer'; 5 | import ora from 'ora'; 6 | import chalk from 'chalk'; 7 | import fetch from 'node-fetch'; 8 | import Conf from 'conf'; 9 | import { BASE_URL, PROJECT_NAME, API_BASE } from '../src/commons.js'; 10 | 11 | // Mock console to prevent actual logging 12 | vi.spyOn(console, 'log').mockImplementation(() => {}); 13 | vi.spyOn(console, 'error').mockImplementation(() => {}); 14 | 15 | // Mock dependencies 16 | vi.mock('inquirer'); 17 | vi.mock('chalk', () => ({ 18 | default: { 19 | green: vi.fn(text => text), 20 | red: vi.fn(text => text), 21 | dim: vi.fn(text => text), 22 | yellow: vi.fn(text => text), 23 | cyan: vi.fn(text => text), 24 | } 25 | })); 26 | vi.mock('node-fetch'); 27 | 28 | // Create a mock spinner object 29 | const mockSpinner = { 30 | start: vi.fn().mockReturnThis(), 31 | succeed: vi.fn().mockReturnThis(), 32 | fail: vi.fn().mockReturnThis(), 33 | info: vi.fn().mockReturnThis(), 34 | }; 35 | 36 | // Mock ora 37 | vi.mock('ora', () => ({ 38 | default: vi.fn(() => mockSpinner) 39 | })); 40 | 41 | // Mock Conf 42 | vi.mock('conf', () => { 43 | return { 44 | default: vi.fn().mockImplementation(() => ({ 45 | set: vi.fn(), 46 | get: vi.fn(), 47 | clear: vi.fn(), 48 | })), 49 | }; 50 | }); 51 | 52 | describe('auth.js', () => { 53 | // let config; 54 | 55 | beforeEach(() => { 56 | vi.clearAllMocks(); 57 | // config = new Conf({ projectName: PROJECT_NAME }); 58 | }); 59 | 60 | describe('login', () => { 61 | it('should login successfully with valid credentials', async () => { 62 | // Mock inquirer response 63 | inquirer.prompt.mockResolvedValue({ 64 | username: 'testuser', 65 | password: 'testpass' 66 | }); 67 | 68 | // Mock fetch response 69 | fetch.mockResolvedValue({ 70 | json: () => Promise.resolve({ 71 | proceed: true, 72 | token: 'testtoken' 73 | }) 74 | }); 75 | 76 | await login(); 77 | 78 | // Verify inquirer was called 79 | expect(inquirer.prompt).toHaveBeenCalled(); 80 | 81 | // Verify fetch was called with correct parameters 82 | expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/login`, { 83 | method: 'POST', 84 | headers: expect.any(Object), 85 | body: JSON.stringify({ 86 | username: 'testuser', 87 | password: 'testpass' 88 | }), 89 | }); 90 | 91 | // Verify spinner methods were called 92 | expect(mockSpinner.start).toHaveBeenCalled(); 93 | expect(mockSpinner.succeed).toHaveBeenCalled(); 94 | }); 95 | 96 | it('should fail login with invalid credentials', async () => { 97 | inquirer.prompt.mockResolvedValue({ username: 'testuser', password: 'testpass' }); 98 | fetch.mockResolvedValue({ 99 | json: vi.fn().mockResolvedValue({ proceed: false }), 100 | ok: true, 101 | }); 102 | 103 | await login(); 104 | expect(mockSpinner.fail).toHaveBeenCalledWith(chalk.red('Login failed. Please check your credentials.')); 105 | }); 106 | 107 | it.skip('should handle login error', async () => { 108 | inquirer.prompt.mockResolvedValue({ username: 'testuser', password: 'testpass' }); 109 | fetch.mockRejectedValue(new Error('Network error')); 110 | 111 | // await expect(login()).rejects.toThrow('Network error'); 112 | expect(mockSpinner.fail).toHaveBeenCalledWith(chalk.red('Failed to login')); 113 | // expect(console.error).toHaveBeenCalledWith(chalk.red('Error: Network error')); 114 | }); 115 | }); 116 | 117 | 118 | describe('logout', () => { 119 | let config; 120 | 121 | beforeEach(() => { 122 | vi.clearAllMocks(); 123 | config = new Conf({ projectName: PROJECT_NAME }); 124 | // config.clear = vi.fn(); 125 | }); 126 | 127 | it.skip('should logout successfully', async () => { 128 | // Mock config.get to return a token 129 | config.get = vi.fn().mockReturnValue('testtoken'); 130 | await logout(); 131 | // Verify config.clear was called 132 | expect(config.clear).toHaveBeenCalled(); 133 | expect(mockSpinner.succeed).toHaveBeenCalledWith(chalk.green('Successfully logged out from Puter!')); 134 | }); 135 | 136 | it('should handle already logged out', async () => { 137 | config.get = vi.fn().mockReturnValue(null); 138 | 139 | await logout(); 140 | 141 | expect(mockSpinner.info).toHaveBeenCalledWith(chalk.yellow('Already logged out')); 142 | }); 143 | 144 | it.skip('should handle logout error', async () => { 145 | config.get = vi.fn().mockReturnValue('testtoken'); 146 | config.clear = vi.fn().mockImplementation(() => { throw new Error('Config error'); }); 147 | 148 | await logout(); 149 | 150 | expect(mockSpinner.fail).toHaveBeenCalled(); 151 | expect(mockSpinner.fail).toHaveBeenCalledWith(chalk.red('Failed to logout')); 152 | }); 153 | 154 | }); 155 | 156 | 157 | describe('getUserInfo', () => { 158 | it('should fetch user info successfully', async () => { 159 | // Mock fetch response 160 | fetch.mockResolvedValue({ 161 | json: () => Promise.resolve({ 162 | username: 'testuser', 163 | uuid: 'testuuid', 164 | email: 'test@example.com', 165 | email_confirmed: true, 166 | is_temp: false, 167 | human_readable_age: '1 year', 168 | feature_flags: { flag1: true, flag2: false }, 169 | }), 170 | ok: true, 171 | }); 172 | 173 | await getUserInfo(); 174 | 175 | // Verify fetch was called with correct parameters 176 | expect(fetch).toHaveBeenCalledWith(`${API_BASE}/whoami`, { 177 | method: 'GET', 178 | headers: expect.any(Object), 179 | }); 180 | }); 181 | 182 | it('should handle fetch user info error', async () => { 183 | // Mock fetch to throw an error 184 | fetch.mockRejectedValue(new Error('Network error')); 185 | 186 | await getUserInfo(); 187 | 188 | // Verify console.error was called 189 | expect(console.error).toHaveBeenCalledWith(chalk.red('Failed to get user info.\nError: Network error')); 190 | }); 191 | }); 192 | 193 | 194 | describe('Authentication', () => { 195 | let config; 196 | 197 | beforeEach(() => { 198 | vi.clearAllMocks(); 199 | config = new Conf({ projectName: PROJECT_NAME }); 200 | }); 201 | 202 | it('should return false if auth token does not exist', () => { 203 | config.get.mockReturnValue(null); 204 | 205 | const result = isAuthenticated(); 206 | 207 | expect(result).toBe(false); 208 | }); 209 | 210 | it('should return null if the auth_token is not defined', () => { 211 | config.get.mockReturnValue(null); 212 | 213 | const result = getAuthToken(); 214 | 215 | expect(result).toBeUndefined(); 216 | }); 217 | 218 | it('should return the current username if it is defined', () => { 219 | config.get.mockReturnValue(null); 220 | 221 | const result = getCurrentUserName(); 222 | 223 | expect(result).toBeUndefined(); 224 | }); 225 | 226 | }); 227 | 228 | // describe('getCurrentDirectory', () => { 229 | // let config; 230 | 231 | // beforeEach(() => { 232 | // vi.clearAllMocks(); 233 | // config = new Conf({ projectName: PROJECT_NAME }); 234 | // // config.get = vi.fn().mockReturnValue('testtoken') 235 | // }); 236 | 237 | // it('should return the current directory', () => { 238 | // config.get.mockReturnValue('/testuser'); 239 | 240 | // const result = getCurrentDirectory(); 241 | 242 | // expect(result).toBe('/testuser'); 243 | // }); 244 | // }); 245 | 246 | describe('getUsageInfo', () => { 247 | it('should fetch usage info successfully', async () => { 248 | fetch.mockResolvedValue({ 249 | json: vi.fn().mockResolvedValue({ 250 | user: [ 251 | { 252 | service: { 'driver.interface': 'interface1', 'driver.method': 'method1', 'driver.implementation': 'impl1' }, 253 | month: 1, 254 | year: 2023, 255 | monthly_usage: 10, 256 | monthly_limit: 100, 257 | policy: { 'rate-limit': { max: 5, period: 30000 } }, 258 | }, 259 | ], 260 | apps: { app1: { used: 5, available: 50 } }, 261 | usages: [{ name: 'usage1', used: 10, available: 100, refill: 'monthly' }], 262 | }), 263 | ok: true, 264 | }); 265 | 266 | await getUsageInfo(); 267 | 268 | expect(fetch).toHaveBeenCalledWith(`${API_BASE}/drivers/usage`, { 269 | method: 'GET', 270 | headers: expect.any(Object), 271 | }); 272 | }); 273 | 274 | it('should handle fetch usage info error', async () => { 275 | fetch.mockRejectedValue(new Error('Network error')); 276 | 277 | await getUsageInfo(); 278 | 279 | expect(console.error).toHaveBeenCalledWith(chalk.red('Failed to fetch usage information.\nError: Network error')); 280 | }); 281 | }); 282 | 283 | 284 | }); -------------------------------------------------------------------------------- /tests/utils.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { formatDate, formatDateTime, formatSize, displayNonNullValues, parseArgs, isValidAppUuid, is_valid_uuid4 } from '../src/utils.js'; 3 | 4 | describe('formatDate', () => { 5 | it('should format a date string correctly', () => { 6 | const dateString = '2024-10-07T15:03:53.000Z'; 7 | const expected = '10/07/2024, 15:03:53'; 8 | expect(formatDate(dateString)).toBe(expected); 9 | }); 10 | 11 | it('should format a date object correctly', () => { 12 | const dateObject = new Date(Date.UTC(2024, 9, 7, 15, 3, 53)); // Month is 0-indexed 13 | const expected = '10/07/2024, 15:03:53'; 14 | expect(formatDate(dateObject)).toBe(expected); 15 | }); 16 | 17 | it('should handle different date and time', () => { 18 | const dateString = '2023-01-01T01:30:05.000Z'; 19 | const expected = '01/01/2023, 01:30:05'; 20 | expect(formatDate(dateString)).toBe(expected); 21 | }); 22 | 23 | it('should handle invalid date', () => { 24 | const dateString = 'invalid-date'; 25 | expect(formatDate(dateString)).toBe('Invalid Date'); 26 | }); 27 | }); 28 | 29 | describe('formatDateTime', () => { 30 | it('should format as time if within 24 hours', () => { 31 | const now = new Date(); 32 | const timestamp = Math.floor(now.getTime() / 1000) - 3600; // 1 hour ago 33 | const expected = new Date(timestamp * 1000).toLocaleTimeString(); 34 | expect(formatDateTime(timestamp)).toBe(expected); 35 | }); 36 | 37 | it('should format as date if older than 24 hours', () => { 38 | const timestamp = Math.floor(Date.now() / 1000) - 86400 * 2; // 2 days ago 39 | const expected = new Date(timestamp * 1000).toLocaleDateString(); 40 | expect(formatDateTime(timestamp)).toBe(expected); 41 | }); 42 | it('should format timestamp 0', () => { 43 | const timestamp = 0; 44 | const expected = new Date(timestamp * 1000).toLocaleDateString(); 45 | expect(formatDateTime(timestamp)).toBe(expected); 46 | }); 47 | }); 48 | 49 | describe('formatSize', () => { 50 | it('should format 0 bytes correctly', () => { 51 | expect(formatSize(0)).toBe('0'); 52 | }); 53 | 54 | it('should format bytes correctly', () => { 55 | expect(formatSize(512)).toBe('512.0 B'); 56 | }); 57 | 58 | it('should format kilobytes correctly', () => { 59 | expect(formatSize(1024 * 2)).toBe('2.0 KB'); 60 | }); 61 | 62 | it('should format megabytes correctly', () => { 63 | expect(formatSize(1024 * 1024 * 3)).toBe('3.0 MB'); 64 | }); 65 | 66 | it('should format gigabytes correctly', () => { 67 | expect(formatSize(1024 * 1024 * 1024 * 4)).toBe('4.0 GB'); 68 | }); 69 | 70 | it('should format terabytes correctly', () => { 71 | expect(formatSize(1024 * 1024 * 1024 * 1024 * 5)).toBe('5.0 TB'); 72 | }); 73 | 74 | it('should handle null and undefined', () => { 75 | expect(formatSize(null)).toBe('0'); 76 | expect(formatSize(undefined)).toBe('0'); 77 | }); 78 | }); 79 | 80 | describe('displayNonNullValues', () => { 81 | it('should display non-null values in a formatted table', () => { 82 | const data = { 83 | name: 'John Doe', 84 | age: 30, 85 | address: { 86 | street: '123 Main St', 87 | city: 'Anytown', 88 | zip: null 89 | }, 90 | email: null 91 | }; 92 | 93 | const consoleLogSpy = vi.spyOn(console, 'log'); 94 | displayNonNullValues(data); 95 | expect(consoleLogSpy).toHaveBeenCalled(); 96 | consoleLogSpy.mockRestore(); 97 | }); 98 | 99 | it('should handle empty object', () => { 100 | const data = {}; 101 | const consoleLogSpy = vi.spyOn(console, 'log'); 102 | displayNonNullValues(data); 103 | expect(consoleLogSpy).toHaveBeenCalled(); 104 | consoleLogSpy.mockRestore(); 105 | }); 106 | 107 | it('should handle nested objects with all null values', () => { 108 | const data = { a: null, b: { c: null, d: null } }; 109 | const consoleLogSpy = vi.spyOn(console, 'log'); 110 | displayNonNullValues(data); 111 | expect(consoleLogSpy).toHaveBeenCalledTimes(5); 112 | consoleLogSpy.mockRestore(); 113 | }); 114 | 115 | it('should handle non-object input', () => { 116 | const data = "not an object"; 117 | const consoleErrorSpy = vi.spyOn(console, 'error'); 118 | displayNonNullValues(data); 119 | expect(consoleErrorSpy).toHaveBeenCalledWith("Invalid input: Input must be a non-null object."); 120 | consoleErrorSpy.mockRestore(); 121 | }); 122 | }); 123 | 124 | describe('parseArgs', () => { 125 | it('should parse simple arguments', () => { 126 | const input = 'command --arg1 val1 --arg2 val2'; 127 | const expected = { _: ['command'], arg1: 'val1', arg2: 'val2' }; 128 | expect(parseArgs(input)).toEqual(expect.objectContaining(expected)); 129 | }); 130 | 131 | it('should parse command line arguments with different types', () => { 132 | const input = 'command --name="John Doe" --age=30'; 133 | const result = parseArgs(input); 134 | expect(result).toEqual({ _: ['command'], name: 'John Doe', age: 30 }); 135 | }); 136 | 137 | it('should parse quoted arguments', () => { 138 | const input = 'command --arg "quoted value"'; 139 | const expected = { _: ['command'], arg: 'quoted value' }; 140 | expect(parseArgs(input)).toEqual(expect.objectContaining(expected)); 141 | }); 142 | 143 | it('should parse arguments with equals sign', () => { 144 | const input = 'command --arg1=val1 --arg2=val2'; 145 | const expected = { _: ['command'], arg1: 'val1', arg2: 'val2' }; 146 | expect(parseArgs(input)).toEqual(expect.objectContaining(expected)); 147 | }); 148 | 149 | it('should handle empty input', () => { 150 | const result = parseArgs(''); 151 | expect(result).toEqual({ _: []}); 152 | }); 153 | 154 | it('should parse empty arguments', () => { 155 | const input = ''; 156 | const expected = { _: [] }; 157 | expect(parseArgs(input)).toEqual(expect.objectContaining(expected)); 158 | }); 159 | }); 160 | 161 | describe('isValidAppUuid', () => { 162 | it('should return true for a valid app UUID', () => { 163 | const uuid = 'app-a1b2c3d4-e5f6-4789-8abc-def012345678'; 164 | expect(isValidAppUuid(uuid)).toBe(true); 165 | }); 166 | 167 | it('should return false if UUID does not start with "app-"', () => { 168 | const uuid = 'a1b2c3d4-e5f6-4789-8abc-def012345678'; 169 | expect(isValidAppUuid(uuid)).toBe(false); 170 | }); 171 | 172 | it('should return false for an invalid UUID after "app-"', () => { 173 | const uuid = 'app-invalid-uuid'; 174 | expect(isValidAppUuid(uuid)).toBe(false); 175 | }); 176 | }); 177 | 178 | describe('is_valid_uuid4', () => { 179 | it('should return true for a valid UUID v4', () => { 180 | const uuid = 'a1b2c3d4-e5f6-4789-8abc-def012345678'; 181 | expect(is_valid_uuid4(uuid)).toBe(true); 182 | }); 183 | 184 | it('should return false for an invalid UUID v4', () => { 185 | const uuid = 'a1b2c3d4-e5f6-5789-8abc-def012345678'; // Invalid version 186 | expect(is_valid_uuid4(uuid)).toBe(false); 187 | }); 188 | 189 | it('should return false for a completely invalid UUID', () => { 190 | const uuid = 'invalid-uuid'; 191 | expect(is_valid_uuid4(uuid)).toBe(false); 192 | }); 193 | }); --------------------------------------------------------------------------------