[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\non:\n  push:\n    branches:\n      - main\n  pull_request:\npermissions:\n  contents: read\njobs:\n  test:\n    name: Test\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout the repository\n        uses: actions/checkout@v3\n      - name: Install pnpm\n        uses: pnpm/action-setup@v2\n        with:\n          version: 7.29.3\n      - name: Install Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: 18\n          cache: pnpm\n      - name: Install all dependencies\n        run: pnpm install --frozen-lockfile\n      - name: Run tests\n        run: pnpm test\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules/\n"
  },
  {
    "path": ".npmignore",
    "content": "pnpm-lock.yaml\n\nexample.png\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Change Log\nThis project adheres to [Semantic Versioning](http://semver.org/).\n\n## 0.9.3\n* Move to the new Github Actions output API.\n\n## 0.9.2\n* Fixed sending all traffic to latest revision.\n\n## 0.9.1\n* Fixed `run` command for server with custom `Dockerfile` and without `dist`.\n\n## 0.9\n* Added `preview` command.\n* Added `close` preview.\n* Added GitHub Deployment to CI.\n* Added workflows for preview pull requests.\n\n## 0.8.1\n* Updated `dotenv`.\n* Updated GitHub Actions config example.\n\n## 0.8\n* Added `pnpm` support.\n\n## 0.7.2\n* Replaced `nanocolors` with `piococolors`.\n* Reduced package size.\n\n## 0.7.1\n* Replaced `colorette` with `nanocolors`.\n\n## 0.7\n* Dropped Node.js 13 support.\n* Reduced dependencies.\n\n## 0.6.21\n* Speed-up Docker build.\n\n## 0.6.20\n* Updated workflow config.\n* Removed `ora`.\n\n## 0.6.19\n* Update deploy workflow example.\n\n## 0.6.18\n* Fix `sign` command.\n\n## 0.6.17\n* Added color output to CI on deploy.\n\n## 0.6.16\n* Fixed `.avif` support.\n\n## 0.6.15\n* Fixed `colorette` imports.\n\n## 0.6.14\n* Replace color output library.\n\n## 0.6.13\n* Reduce dependencies.\n\n## 0.6.12\n* Fix Node.js 14 support.\n\n## 0.6.11\n* Improve image downloading output.\n\n## 0.6.10\n* Use `stderr` for debug messages.\n\n## 0.6.9\n* Remove color from GitHub Actions output variable.\n\n## 0.6.8\n* Fix GitHub Actions output variable.\n\n## 0.6.7\n* Fix mark to check `ssdeploy changed` hash.\n\n## 0.6.6\n* Use configs in `ssdeploy changed` hash.\n* Remove white spaces from `WEBSITE_URL` environment variable too.\n* Fix GitHub Actions output variable.\n* Fix text in `ssdeploy changed`.\n\n## 0.6.5\n* Fix error message.\n\n## 0.6.4\n* Fix loading previous hash in `ssdeploy changed`.\n\n## 0.6.3\n* Fix syntax error in GitHub Actions workflow.\n\n## 0.6.2\n* Clean up GitHub Actions workflow.\n\n## 0.6.1\n* Fix GitHub Actions workflow for `ssdeploy changed`.\n\n## 0.6\n* Do not spend resources to deploy website if files were not changed.\n* Fix colors on GitHub Actions.\n\n## 0.5.1\n* Fix `trim()` error on default region environment variable.\n\n## 0.5\n* Add `npm` support.\n* Add `--verbose` by default to deploy workflow.\n* Fix error on new line and spaces in environment variables.\n\n## 0.4.2\n* Fix deploy without previous images to clean.\n\n## 0.4.1\n* Show step number during the build.\n\n## 0.4\n* Rename `node_modules/ssdeploy/purge` to `node_modules/ssdeploy/purge.js`.\n* Move configs to `node_modules/ssdeploy/configs`.\n\n## 0.3.1\n* Fix cleaned images count.\n\n## 0.3\n* Show the size of the image after build.\n\n## 0.2.4\n* Fix first deploy.\n* Fix deploy on local laptop.\n\n## 0.2.3\n* Clean up GitHub Actions workflow.\n\n## 0.2.2\n* Add `--verbose` support to `ssdeploy run` and `ssdeploy shell`.\n\n## 0.2.1\n* Do not open browser on `ssdeploy shell`.\n\n## 0.2\n* Rename project from `solid-state-deploy` to `ssdeploy`.\n* Add `shell` command.\n\n## 0.1\n* Initial release.\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright 2019 Andrey Sitnik <andrey@sitnik.ru>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Solid State Deploy\n\nDeploy simple websites with Google Cloud and Cloudflare.\nIt is like Netlify with:\n\n* **Better performance.** Cloudflare has more features for fast websites\n  than Netlify CDN. For instance, HTTP/3 and TLS 1.3 0-RTT.\n* **Flexibility.** You can have crontab jobs and simple scripts\n  (without persistence storage). You will have powerful and well documented\n  Nginx config to define custom headers and redirects.\n* **Lack of vendor lock-in.** We use industry standards like Docker\n  and Nginx. You can change CI, CDN, or Docker cloud separately.\n* **Local tests.** You can run a server on your laptop to test redirects\n  and scripts.\n\nYou will have built-in HTTPS and deploy by `git push`.\n\n<img src=\"./example.png\" alt=\"ssdeploy example\" width=\"593\">\n\nWe also have trade-offs. It is not free, but for a simple website,\nit will cost you cents per month. You need more steps to install it,\nbut after you have the same simple workflow.\n\n<a href=\"https://evilmartians.com/?utm_source=ssdeploy\">\n  <img src=\"https://evilmartians.com/badges/sponsored-by-evil-martians.svg\"\n       alt=\"Sponsored by Evil Martians\" width=\"236\" height=\"54\">\n</a>\n\n## Install\n\n1. Create an account in [Google Cloud].\n2. Go to **IAM & Admin** → **Service Accounts**,\n   click **Create Service Account** and fill form:\n   * Service Account Name: `Github-actions`\n   * Service Account Description: `Deploy from Github Actions`\n3. Add **Cloud Run Admin**, **Storage Admin**, **Service Account User** roles.\n4. Click **Create Key**, choose **JSON** → **Create**, download and keep\n   file for a while.\n5. Open **Container Registry** and enable the service.\n6. Open **Cloud Run** and start the service.\n7. Go to your Github page of your project at **Settings** → **Secrets**.\n8. Add new secret `WEBSITE_URL` with URL to your website domain\n   (like `example.com`).\n9. Add new secret `GCLOUD_PROJECT` with Google Cloud project name like\n   `test-255417`. You can find project ID by opening a project switcher\n   at the top of [Google Cloud].\n10. Choose application name (like `examplecom`) and add `GCLOUD_APP` secret with\n   this name.\n11. Call `base64 key-partition-….json` (file from step 4) and add `GCLOUD_AUTH`\n    secret with the base64 content of this file.\n12. Install Solid State Deploy to your project.\n\n    ```sh\n    npm i ssdeploy\n    ```\n13. Create Github Actions workflow by calling:\n\n    ```sh\n    npx ssdeploy init\n    ```\n14. Your project should build HTML files by `npm build` and put them to `dist/`.\n15. Push the project’s changes to Github Actions to start deploying.\n    Open **Actions** tab on Github to check out the process.\n16. Go to **Cloud Run** at [Google Cloud] and find your server. Open it\n    by clicking on the name and find the URL like `examplecom-hjv54hv.a.run.app`.\n    Check that the website is working.\n17. Click on **Manage Custom Domains** → **Add mapping**. Select your app,\n    **Verify a new domain**, and enter your domain name.\n    Finish domain verification with Webmaster Central.\n18. After verification open **Add mapping** dialog again, select your app,\n    domain, and leave subdomain blank. You will get `A` and `AAAA` records.\n19. Create a new [Cloudflare] account.\n    Create a site with `A` and `AAAA` records from Cloud Run.\n20. Enable **HTTP/3** and **0-RTT** in Cloudflare **Network** settings.\n21. Find **Zone ID** at site overview and create **API token**\n    with `cache cleaner` name and `Cache Purge`/`Edit` permission.\n22. Use them in `CLOUDFLARE_ZONE` and `CLOUDFLARE_TOKEN` secrets at Github.\n23. Go to Google Cloud Run, **Manage Custom Domains** → **Add mapping**\n    to add `www` subdomain and add `CNAME` record to Cloudflare **DNS**\n    settings.\n\nWe recommend checking the final result\n[for blocking in Russia](https://isitblockedinrussia.com/) and recreate\nCloudflare account to change IP addressed.\n\nFew extra steps will improve security:\n\n1. Go to Cloudflare **SSL/TLS** settings and enable **Full** encryption mode.\n2. Add `CAA` records to Cloudflare **DNS** settings:\n\n   ```js\n   CAA @   0 \"only allow specific hostname\" digicert.com\n   CAA @   0 \"only allow specific hostname\" letsencrypt.org\n   CAA www 0 \"only allow specific hostname\" digicert.com\n   CAA www 0 \"only allow specific hostname\" letsencrypt.org\n   ```\n3. Enable **DNSSEC** in **DNS** settings.\n4. Enable **HTST** by creating `nginx.conf` in the root of your project with:\n\n   ```cpp\n   add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains; preload\";\n   add_header X-Content-Type-Options \"nosniff\";\n   ```\n\n[Google Cloud]: https://console.cloud.google.com/\n[Cloudflare]: https://www.cloudflare.com/\n\n\n## Deploy\n\nJust push commits to `master`:\n\n```sh\ngit push origin master\n```\n\nYou can switch deploy branch at `.Github/workflows/deploy.yml`.\n\n\n## Run Server Locally\n\nTo test the Docker image locally run:\n\n```sh\nnpm build\nnpx ssdeploy run\n```\n\n## Deploy Server Locally\n\nYou can deploy a server from the laptop. It can be useful to debug.\n\nYou need to install [Google Cloud SDK](https://cloud.google.com/sdk/install)\nand call:\n\n```sh\nnpx ssdeploy deploy\n```\n\n\n## Custom Nginx config\n\nIn custom Nginx config, you can define headers and redirects. Create `nginx.conf`\nin your project root.\n\n```cpp\nif ($host ~ ^www\\.(?<domain>.+)$) {\n  return 301 https://$domain$request_uri;\n}\n\nlocation ~* \"(\\.css|\\.png|\\.svg|\\.woff2)$\" {\n  add_header Cache-Control \"public, max-age=31536000, immutable\";\n}\n```\n\nIt will be included inside the `server` context.\n\n\n## Custom Docker config\n\nCustom `Dockerfile` should be placed at your project root. It can be used\nto define crontab jobs:\n\n```sh\nFROM nginx:alpine\nRUN rm -R /etc/nginx/conf.d\nCOPY ./dist/ /var/www/\nCOPY ./node_modules/ssdeploy/configs/nginx.conf /etc/nginx/nginx.template\nCOPY ./nginx.conf /etc/nginx/server.conf\nRUN echo \"#\\!/bin/sh\\necho 1\" > /etc/periodic/hourly/example\nRUN chmod a+x /etc/periodic/hourly/example\nCMD crond && envsubst \\$PORT < /etc/nginx/nginx.template > /etc/nginx/nginx.conf && nginx\n```\n"
  },
  {
    "path": "bin.js",
    "content": "#!/usr/bin/env node\n\nimport dotenv from 'dotenv'\nimport pico from 'picocolors'\n\nimport deploy, { stopPreview } from './lib/deploy.js'\nimport showVersion from './lib/show-version.js'\nimport showHelp from './lib/show-help.js'\nimport runImage from './lib/run-image.js'\nimport changed from './lib/changed.js'\nimport purge from './lib/purge.js'\nimport init from './lib/init.js'\nimport sign from './lib/sign.js'\n\ndotenv.config()\n\nfunction getPullRequestId() {\n  let pr = process.argv[3]\n  if (!pr) {\n    process.stderr.write(pico.red('Missed pull request ID') + '\\n')\n    process.exit(1)\n  }\n  return pr\n}\n\nasync function run() {\n  let command = process.argv[2]\n  if (command === '--version') {\n    await showVersion()\n  } else if (!command || command === 'help' || command === '--help') {\n    showHelp()\n  } else if (command === 'init') {\n    await init()\n  } else if (command === 'shell') {\n    await runImage('/bin/sh')\n  } else if (command === 'run') {\n    await runImage()\n  } else if (command === 'purge') {\n    await purge()\n  } else if (command === 'changed') {\n    await changed()\n  } else if (command === 'deploy') {\n    await deploy()\n  } else if (command === 'preview') {\n    await deploy(getPullRequestId())\n  } else if (command === 'close') {\n    await stopPreview(getPullRequestId())\n  } else if (command === 'sign') {\n    let file = process.argv[3]\n    if (!file) {\n      process.stderr.write(pico.red('Missed file to sign') + '\\n')\n      process.exit(1)\n    }\n    await sign(file)\n  } else {\n    process.stderr.write(pico.red(`Unknown command ${command}`) + '\\n\\n')\n    showHelp()\n    process.exit(1)\n  }\n}\n\nrun().catch(e => {\n  if (!e.own) process.stderr.write(pico.red(e.stack) + '\\n')\n  process.exit(1)\n})\n"
  },
  {
    "path": "configs/.dockerignore",
    "content": "node_modules/\n!node_modules/ssdeploy/configs/\n.github/\n.cache/\n.git/\nsrc/\n.gitignore\n.editorconfig\nREADME.md\nLICENSE\npackage.json\nyarn.lock\npnpm-lock.yaml\n"
  },
  {
    "path": "configs/Dockerfile",
    "content": "FROM nginx:alpine\nRUN rm -R /etc/nginx/conf.d\nCOPY ./nginx-template.conf /etc/nginx/nginx.template\nCOPY ./nginx.conf /etc/nginx/server.conf\nCOPY ./dist/ /var/www/\nCMD envsubst \\$PORT < /etc/nginx/nginx.template > /etc/nginx/nginx.conf && nginx\n"
  },
  {
    "path": "configs/close.yml",
    "content": "name: Clean Preview\non:\n  pull_request:\n    types: [ closed ]\njobs:\n  close:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Clean from GitHub\n        uses: bobheadxi/deployments@v1\n        with:\n          step: delete-env\n          token: ${{ secrets.GITHUB_TOKEN }}\n          env: preview-${{ github.event.number }}\n      - name: Checkout the repository\n        uses: actions/checkout@v3\n      - name: Install Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: 18\n          cache: npm\n      - name: Install dependencies\n        run: npm install --production --frozen-lockfile\n      - name: Auth Google Cloud\n        uses: google-github-actions/auth@v0\n        with:\n          credentials_json: ${{ secrets.GCLOUD_AUTH }}\n      - name: Install Google Cloud\n        uses: google-github-actions/setup-gcloud@v0\n      - name: Clean from Google Cloud\n        run: ./node_modules/.bin/ssdeploy close $PR --verbose\n        env:\n          PR: ${{ github.event.number }}\n          GCLOUD_APP: ${{ secrets.GCLOUD_APP }}\n          GCLOUD_PROJECT: ${{ secrets.GCLOUD_PROJECT }}\n          CLOUDFLARE_ZONE: ${{ secrets.CLOUDFLARE_ZONE }}\n          CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }}\n"
  },
  {
    "path": "configs/deploy.yml",
    "content": "name: Deploy\non:\n  push:\n    branches:\n      - main\nenv:\n  FORCE_COLOR: 2\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout the repository\n        uses: actions/checkout@v3\n      - name: Install Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: 18\n          cache: npm\n      - name: Install dependencies\n        run: npm install --production --frozen-lockfile\n      - name: Build static files\n        run: npm build\n      - name: Check files changes\n        id: hash\n        run: ./node_modules/.bin/ssdeploy changed\n        env:\n          WEBSITE_URL: ${{ secrets.WEBSITE_URL }}\n      - name: Auth Google Cloud\n        uses: google-github-actions/auth@v0\n        with:\n          credentials_json: ${{ secrets.GCLOUD_AUTH }}\n      - name: Install Google Cloud\n        if: \"!steps.hash.outputs.noChanges\"\n        uses: google-github-actions/setup-gcloud@v0\n      - name: Deploy files\n        if: \"!steps.hash.outputs.noChanges\"\n        run: ./node_modules/.bin/ssdeploy deploy --verbose\n        env:\n          GCLOUD_APP: ${{ secrets.GCLOUD_APP }}\n          GCLOUD_PROJECT: ${{ secrets.GCLOUD_PROJECT }}\n          CLOUDFLARE_ZONE: ${{ secrets.CLOUDFLARE_ZONE }}\n          CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }}\n"
  },
  {
    "path": "configs/nginx-template.conf",
    "content": "worker_processes 1;\npid /run/nginx.pid;\ndaemon off;\n\nevents {\n  worker_connections 1024;\n}\n\nhttp {\n  access_log off;\n  error_log stderr error;\n  server_tokens off;\n\n  include mime.types;\n  types {\n    application/manifest+json webmanifest;\n  }\n  default_type application/octet-stream;\n  charset_types application/javascript text/css application/manifest+json image/svg+xml;\n  sendfile on;\n\n  server {\n    listen $PORT;\n\n    root /var/www;\n    charset UTF-8;\n    gzip_static on;\n\n    include /etc/nginx/server.conf;\n  }\n}\n"
  },
  {
    "path": "configs/preview.yml",
    "content": "name: Preview\non:\n  pull_request:\nenv:\n  FORCE_COLOR: 2\njobs:\n  preview:\n    runs-on: ubuntu-latest\n    if: github.ref != 'refs/heads/main'\n    steps:\n      - name: Notify about new deployment\n        uses: bobheadxi/deployments@v1\n        id: deployment\n        with:\n          step: start\n          token: ${{ secrets.GITHUB_TOKEN }}\n          ref: ${{ github.head_ref }}\n          env: preview-${{ github.event.number }}\n      - name: Checkout the repository\n        uses: actions/checkout@v3\n      - name: Install Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: 18\n          cache: npm\n      - name: Install dependencies\n        run: npm install --production --frozen-lockfile\n      - name: Build static files\n        run: npm build\n      - name: Check files changes\n        id: hash\n        run: ./node_modules/.bin/ssdeploy changed\n        env:\n          WEBSITE_URL: ${{ secrets.WEBSITE_URL }}\n      - name: Auth Google Cloud\n        uses: google-github-actions/auth@v0\n        with:\n          credentials_json: ${{ secrets.GCLOUD_AUTH }}\n      - name: Install Google Cloud\n        uses: google-github-actions/setup-gcloud@v0\n      - name: Deploy files\n        id: deploy\n        run: ./node_modules/.bin/ssdeploy preview $PR --verbose\n        env:\n          PR: ${{ github.event.number }}\n          GCLOUD_APP: ${{ secrets.GCLOUD_APP }}\n          GCLOUD_PROJECT: ${{ secrets.GCLOUD_PROJECT }}\n          CLOUDFLARE_ZONE: ${{ secrets.CLOUDFLARE_ZONE }}\n          CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }}\n      - name: Update deployment status\n        uses: bobheadxi/deployments@v1\n        if: always()\n        with:\n          step: finish\n          token: ${{ secrets.GITHUB_TOKEN }}\n          status: ${{ job.status }}\n          env: ${{ steps.deployment.outputs.env }}\n          env_url: ${{ steps.deploy.outputs.url }}\n          deployment_id: ${{ steps.deployment.outputs.deployment_id }}\n"
  },
  {
    "path": "lib/build.js",
    "content": "import { writeFile, copyFile, unlink } from 'fs/promises'\nimport { basename, join } from 'path'\nimport { spawn, exec } from 'child_process'\nimport { existsSync } from 'fs'\nimport bytes from 'bytes'\nimport pico from 'picocolors'\n\nimport debug, { debugCmd } from './debug.js'\nimport { findPackageDir } from './find-package-up.js'\nimport detectDocker from './detect-docker.js'\nimport showSpinner from './show-spinner.js'\nimport { CONFIGS } from './dirs.js'\n\nfunction getSize(bin, name, env) {\n  return new Promise(resolve => {\n    exec(\n      `${bin} image inspect ${name}:latest --format='{{.Size}}'`,\n      { env },\n      (error, stdout) => {\n        if (error) {\n          resolve()\n        } else {\n          resolve(parseInt(stdout))\n        }\n      }\n    )\n  })\n}\n\nexport default async function build(name, env = process.env, forceDocker) {\n  let root = await findPackageDir()\n  if (!name) name = basename(root)\n  let bin = forceDocker ? 'docker' : await detectDocker()\n\n  let customDocker = join(root, 'Dockerfile')\n  let localDocker = join(CONFIGS, 'Dockerfile')\n  let customNginxTemplate = join(root, 'nginx-template.conf')\n  let localNginxTemplate = join(CONFIGS, 'nginx-template.conf')\n  let customIgnore = join(root, '.dockerignore')\n  let localIgnore = join(CONFIGS, '.dockerignore')\n  let nginxPath = join(root, 'nginx.conf')\n\n  let dockerfile = localDocker\n  if (existsSync(customDocker)) dockerfile = customDocker\n\n  let args = ['build', '-f', dockerfile, '-t', name, '.']\n  debugCmd(bin + ' ' + args.join(' '))\n\n  let text = 'Building Docker image'\n  if (bin === 'podman') text = 'Building Podman image'\n  let spinner = showSpinner(text)\n\n  let temp = []\n  try {\n    if (!existsSync(customIgnore)) {\n      await copyFile(localIgnore, customIgnore)\n      temp.push(customIgnore)\n    }\n    if (!existsSync(nginxPath)) {\n      await writeFile(nginxPath, '\\n')\n      temp.push(nginxPath)\n    }\n    if (!existsSync(customNginxTemplate)) {\n      await copyFile(localNginxTemplate, customNginxTemplate)\n      temp.push(customNginxTemplate)\n    }\n\n    await new Promise((resolve, reject) => {\n      let docker = spawn(bin, args, { env })\n      let steps = 0\n      docker.stdout.on('data', data => {\n        if (!process.env.CI && !process.env.GITHUB_ACTIONS) {\n          if (data.toString().startsWith('STEP ')) {\n            steps += 1\n            process.stdout.write(`${text}: step ${steps}\\n`)\n          }\n        }\n        debug(data)\n      })\n      let output = ''\n      docker.stderr.on('data', data => {\n        output += data.toString()\n      })\n      docker.on('exit', code => {\n        if (code === 0) {\n          resolve()\n        } else {\n          spinner.fail()\n          process.stderr.write(pico.red(output))\n          let err = new Error('Docker error ' + code)\n          err.own = true\n          reject(err)\n        }\n      })\n    })\n  } finally {\n    await Promise.all(temp.map(i => unlink(i)))\n  }\n\n  let size = await getSize(bin, name, env)\n  if (size) {\n    spinner.succeed(`Built ${bytes(size, { unitSeparator: ' ' })} image`)\n  }\n\n  return name\n}\n"
  },
  {
    "path": "lib/call-cloudflare.js",
    "content": "import { request } from 'https'\n\nexport default async function callCloudflare (command, opts) {\n  await new Promise((resolve, reject) => {\n    let req = request(\n      {\n        method: 'POST',\n        hostname: 'api.cloudflare.com',\n        path: `/client/v4/zones/${process.env.CLOUDFLARE_ZONE}/${command}`,\n        headers: {\n          'Authorization': `Bearer ${process.env.CLOUDFLARE_TOKEN}`,\n          'Content-Type': 'application/json'\n        }\n      },\n      res => {\n        let data = ''\n        res.on('data', chunk => {\n          data += chunk\n        })\n        res.on('end', () => {\n          let answer\n          try {\n            answer = JSON.parse(data)\n          } catch {\n            console.error(data)\n            process.exit(1)\n          }\n          if (answer.success) {\n            resolve()\n          } else {\n            reject(new Error(answer.errors[0].message))\n          }\n        })\n      }\n    )\n    if (opts) req.write(JSON.stringify(opts))\n    req.on('error', reject)\n    req.end()\n  })\n}\n"
  },
  {
    "path": "lib/changed.js",
    "content": "import { existsSync, createReadStream } from 'fs'\nimport { writeFile } from 'fs/promises'\nimport folderHash from 'folder-hash'\nimport { join } from 'path'\nimport { get } from 'https'\nimport hasha from 'hasha'\nimport pico from 'picocolors'\n\nimport showError, { showWarning } from './show-error.js'\nimport showSpinner, { wrap } from './show-spinner.js'\nimport { findPackageDir } from './find-package-up.js'\n\nasync function fileHash(file) {\n  if (!existsSync(file)) return ''\n  return hasha.fromStream(createReadStream(file))\n}\n\nasync function loadPrev(url) {\n  if (url.startsWith('http://')) {\n    throw showError('`ssdeploy changed` supports only https:// websites')\n  }\n  if (!url.startsWith('https://')) {\n    url = 'https://' + url\n  }\n\n  try {\n    new URL(url)\n  } catch (e) {\n    if (e.code === 'ERR_INVALID_URL') {\n      throw showError(\n        'Ivalid URL `' + url + '` in `WEBSITE_URL` environment variables'\n      )\n    } else {\n      throw e\n    }\n  }\n\n  if (!url.endsWith('/')) url += '/'\n  let hashUrl = url + 'ssdeploy-hash.txt'\n\n  let spinner = showSpinner('Loading hash from previous deploy')\n\n  return new Promise(resolve => {\n    function error(e) {\n      spinner.fail()\n      if (typeof e === 'string') {\n        showWarning(e)\n      } else {\n        showWarning(e.message)\n      }\n      resolve('')\n    }\n\n    let req = get(hashUrl, res => {\n      if (res.statusCode === 404) {\n        error(\n          '`' +\n            hashUrl +\n            '` file was not found.\\n' +\n            'It is OK for first deploy.'\n        )\n        return\n      } else if (res.statusCode !== 200) {\n        error(\n          'Your website return ' +\n            res.statusCode +\n            ' code ' +\n            'on `' +\n            hashUrl +\n            '` request'\n        )\n      }\n      let data = ''\n      res.on('data', chunk => {\n        data += chunk.toString()\n      })\n      res.on('end', () => {\n        if (data.endsWith('=')) {\n          spinner.succeed()\n          resolve(data)\n        } else {\n          error('Wrong data found at `' + hashUrl + '`')\n        }\n      })\n    })\n    req.on('error', error)\n    req.end()\n  })\n}\n\nasync function calcCurrent() {\n  let root = await findPackageDir()\n  let dist = join(root, 'dist')\n  let ignoreFile = join(root, '.dockerignore')\n  let dockerFile = join(root, 'Dockerfile')\n  let nginxFile = join(root, 'nginx.conf')\n  let workflowFile = join(root, '.github', 'workflows', 'deploy.yml')\n  let [{ hash }, workflow, ignore, docker, nginx] = await Promise.all([\n    folderHash.hashElement(dist),\n    fileHash(workflowFile),\n    fileHash(ignoreFile),\n    fileHash(dockerFile),\n    fileHash(nginxFile)\n  ])\n  return [dist, hasha(hash + workflow + ignore + docker + nginx) + '=']\n}\n\nexport default async function changed() {\n  if (!process.env.WEBSITE_URL) {\n    showWarning(\n      'Set `WEBSITE_URL` environment variables at your CI ',\n      'to deploy websites only when files will be changed'\n    )\n    return\n  }\n\n  let [prev, [dist, current]] = await Promise.all([\n    loadPrev(process.env.WEBSITE_URL.trim()),\n    calcCurrent()\n  ])\n  await wrap('Writing ssdeploy-hash.txt', async () => {\n    await writeFile(join(dist, 'ssdeploy-hash.txt'), current)\n  })\n  if (prev === current) {\n    process.stderr.write(\n      pico.yellow('Files were not changed from latest deploy. Stop deploy.\\n')\n    )\n    await writeFile(process.env.GITHUB_OUTPUT, 'noChanges=1\\n')\n  } else {\n    if (prev !== '') {\n      process.stderr.write('Files was changed. ')\n    }\n    process.stderr.write('Continue deploy.\\n')\n  }\n}\n"
  },
  {
    "path": "lib/debug.js",
    "content": "import pico from 'picocolors'\n\nexport let isDebug = process.argv.includes('--verbose')\n\nexport function debugCmd(cmd) {\n  debug(pico.gray('$ ' + cmd) + '\\n')\n}\n\nexport default function debug(text) {\n  if (isDebug) {\n    process.stderr.write(text)\n  }\n}\n"
  },
  {
    "path": "lib/deploy.js",
    "content": "import { spawn, execSync } from 'child_process'\nimport { writeFile } from 'fs/promises'\nimport pico from 'picocolors'\n\nimport debug, { isDebug, debugCmd } from './debug.js'\nimport { wrap } from './show-spinner.js'\nimport showError from './show-error.js'\nimport build from './build.js'\nimport purge from './purge.js'\n\nlet safeEnv = {}\nfor (let i in process.env) {\n  if (!/^(GCLOUD_|CLOUDFLARE_)/.test(i)) safeEnv[i] = process.env[i]\n}\n\nasync function exec(command, opts) {\n  return new Promise((resolve, reject) => {\n    debugCmd(command)\n    let cmd = spawn(command, { ...opts, env: safeEnv, shell: '/bin/bash' })\n    let stdout = ''\n    let stderr = ''\n    cmd.stdout.on('data', data => {\n      let out = data.toString()\n      stdout += out\n      debug(out)\n    })\n    cmd.stderr.on('data', data => {\n      stderr += data.toString()\n      debug(pico.yellow(data))\n    })\n    cmd.on('exit', code => {\n      if (code === 0) {\n        resolve(stdout.trim())\n      } else if (isDebug) {\n        reject(showError('Exit code ' + code))\n      } else {\n        reject(showError(stderr))\n      }\n    })\n  })\n}\n\nasync function push(image) {\n  await wrap('Pushing image to Google Cloud Registry', async () => {\n    await exec('gcloud auth configure-docker --quiet')\n    await exec(`docker push ${image}`)\n  })\n}\n\nasync function setPreviewUrl(project, app, region = 'us-east1', preview) {\n  let base = execSync(\n    `gcloud run services describe ${app} ` +\n      `--project ${project} --region ${region} --format 'value(status.url)'`\n  )\n    .toString()\n    .trim()\n  let url = base.replace('https://', `https://preview-${preview}---`)\n  await writeFile(process.env.GITHUB_OUTPUT, `url=${url}\\n`)\n}\n\nasync function run(image, project, app, region, preview) {\n  await wrap('Starting image on Google Cloud Run', async spinner => {\n    await exec(\n      `gcloud run deploy ${app} --image ${image} ` +\n        `--project ${project} --region=${region} ` +\n        '--platform managed --allow-unauthenticated' +\n        (preview ? ` --tag preview-${preview} --no-traffic` : '')\n    )\n    spinner.succeed(`Image was deployed at ${region} server`)\n  })\n}\n\nasync function cleanOldImages(image, tag) {\n  await wrap('Cleaning registry from old images', async spinner => {\n    let removed = 0\n    let filter = '-tags:*'\n    if (tag) filter = `tags:${tag}`\n    let out = await exec(\n      'gcloud container images list-tags ' +\n        `${image} --filter='${filter}' --format='get(digest)'`\n    )\n    if (out !== '') {\n      await Promise.all(\n        out.split('\\n').map(i => {\n          removed += 1\n          return exec(\n            `gcloud container images delete '${image}@${i}' --quiet` +\n              (tag ? '  --force-delete-tags' : '')\n          )\n        })\n      )\n    }\n    spinner.succeed(`Removed ${removed} images`)\n  })\n}\n\nasync function migrateTraffic(project, app, region) {\n  await wrap('Moving traffic to new revision', async () => {\n    await exec(\n      'gcloud run services update-traffic ' +\n        `${app} --project ${project} --region=${region} --to-latest`\n    )\n  })\n}\n\nexport default async function deploy(preview) {\n  if (!process.env.GCLOUD_PROJECT || !process.env.GCLOUD_APP) {\n    throw showError(\n      'Set `GCLOUD_PROJECT` and `GCLOUD_APP` environment variables at your CI'\n    )\n  }\n\n  let project = process.env.GCLOUD_PROJECT.trim()\n  let app = process.env.GCLOUD_APP.trim()\n  let region = process.env.GCLOUD_REGION || 'us-east1'\n  let image = `gcr.io/${project}/${app}`\n  let taggedImage = preview ? `${image}:preview-${preview}` : image\n\n  await build(taggedImage, safeEnv, true, preview)\n  await push(taggedImage)\n  await run(taggedImage, project, app, region, preview)\n  if (preview) {\n    await setPreviewUrl(project, app, region, preview)\n  } else {\n    await migrateTraffic(project, app, region)\n    await purge()\n  }\n  await cleanOldImages(image)\n}\n\nexport async function stopPreview(preview) {\n  let project = process.env.GCLOUD_PROJECT.trim()\n  let app = process.env.GCLOUD_APP.trim()\n  let image = `gcr.io/${project}/${app}`\n  let region = process.env.GCLOUD_REGION || 'us-east1'\n\n  await exec(\n    `gcloud run services update-traffic ${app} ` +\n      `--region ${region} --project ${project} ` +\n      `--remove-tags preview-${preview} --async`\n  )\n  await cleanOldImages(image, `preview-${preview}`)\n}\n"
  },
  {
    "path": "lib/detect-docker.js",
    "content": "import { promisify } from 'util'\nimport child from 'child_process'\n\nlet exec = promisify(child.exec)\nlet runner\n\nexport default async function detectDocker () {\n  if (!runner) {\n    try {\n      await exec('podman --version')\n      runner = 'podman'\n    } catch (e) {\n      runner = 'docker'\n    }\n  }\n  return runner\n}\n"
  },
  {
    "path": "lib/dirs.js",
    "content": "import { dirname, join } from 'path'\n\nlet self = new URL(import.meta.url).pathname\n\nexport const ROOT = join(dirname(self), '..')\nexport const CONFIGS = join(ROOT, 'configs')\n"
  },
  {
    "path": "lib/find-package-up.js",
    "content": "import { resolve, parse, dirname } from 'path'\nimport { existsSync } from 'fs'\n\nimport showError from './show-error.js'\n\nasync function findUp(name, cwd = '') {\n  let directory = resolve(cwd)\n  let { root } = parse(directory)\n\n  while (true) {\n    let foundPath = await resolve(directory, name)\n\n    if (existsSync(foundPath)) {\n      return foundPath\n    }\n\n    if (directory === root) {\n      return undefined\n    }\n\n    directory = dirname(directory)\n  }\n}\n\nexport async function findPackageUp(cwd) {\n  let path = await findUp('package.json', cwd)\n  if (!path) throw showError('Can’t find package.json')\n  return path\n}\n\nexport async function findPackageDir(cwd) {\n  return dirname(await findPackageUp(cwd))\n}\n"
  },
  {
    "path": "lib/init.js",
    "content": "import { readFile, mkdir, writeFile } from 'fs/promises'\nimport { dirname, join } from 'path'\nimport { existsSync } from 'fs'\n\nimport { findPackageDir } from './find-package-up.js'\nimport { CONFIGS } from './dirs.js'\n\nasync function copyTemplate(manager, name, to) {\n  await mkdir(dirname(to), { recursive: true })\n  let template = await readFile(join(CONFIGS, name))\n\n  let config = template.toString()\n  if (manager === 'yarn') {\n    config = config\n      .replace('cache: npm', 'cache: yarn')\n      .replace(' npm ', ' yarn ')\n  } else if (manager === 'pnpm') {\n    config = config\n      .replace('cache: npm', 'cache: pnpm')\n      .replace(' npm ', ' pnpm ')\n  }\n\n  await writeFile(join(to, name), config)\n}\n\nexport default async function init() {\n  let root = await findPackageDir()\n  let workflows = join(root, '.github', 'workflows')\n\n  let manager = 'npm'\n  if (existsSync(join(root, 'yarn.lock'))) {\n    manager = 'yarn'\n  } else if (existsSync(join(root, 'pnpm-lock.yaml'))) {\n    manager = 'pnpm'\n  }\n\n  await Promise.all([\n    copyTemplate(manager, workflows, 'deploy.yml'),\n    copyTemplate(manager, workflows, 'preview.yml'),\n    copyTemplate(manager, workflows, 'close.yml')\n  ])\n}\n"
  },
  {
    "path": "lib/purge.js",
    "content": "import callCloudflare from './call-cloudflare.js'\nimport { showWarning } from './show-error.js'\nimport { wrap } from './show-spinner.js'\n\nexport default async function purge () {\n  if (!process.env.CLOUDFLARE_ZONE || !process.env.CLOUDFLARE_TOKEN) {\n    showWarning(\n      'Get zone ID and API token in CLoudflare dashboard',\n      'and set `CLOUDFLARE_TOKEN` and `CLOUDFLARE_ZONE`',\n      'environment variables at your CI'\n    )\n  } else {\n    await wrap('Cleaning CDN cache', async spinner => {\n      await callCloudflare('purge_cache', { purge_everything: true })\n      spinner.succeed('CDN cache was cleaned')\n    })\n  }\n}\n"
  },
  {
    "path": "lib/run-image.js",
    "content": "import { existsSync } from 'fs'\nimport { spawn } from 'child_process'\nimport { join } from 'path'\nimport open from 'open'\nimport pico from 'picocolors'\n\nimport { findPackageDir } from './find-package-up.js'\nimport detectDocker from './detect-docker.js'\nimport { debugCmd } from './debug.js'\nimport build from './build.js'\n\nlet y = pico.yellow\n\nexport default async function runImage(script) {\n  let bin = await detectDocker()\n  let name = await build()\n  let root = await findPackageDir()\n  let args = ['run']\n  if (existsSync(join(root, 'dist'))) {\n    args = args.concat(['-v', './dist/:/var/www/'])\n  }\n  args = args.concat([\n    '--privileged',\n    '--rm',\n    '-p',\n    '8000:80',\n    '-e',\n    'PORT=80',\n    '-it',\n    name\n  ])\n  if (script) args.push(script)\n  debugCmd(bin + ' ' + args.join(' '))\n  spawn(bin, args, { stdio: 'inherit' })\n  if (!script) {\n    let ctrlC = 'Ctrl+C'\n    if (process.platform === 'darwin') ctrlC = 'Cmd + .'\n    process.stderr.write(\n      `  Website is available to test at ${y('http://localhost:8000/')}\\n` +\n        `  Press ${y(ctrlC)} to stop the server\\n`\n    )\n    open('http://localhost:8000/')\n  }\n}\n"
  },
  {
    "path": "lib/show-error.js",
    "content": "import pico from 'picocolors'\n\nexport function showWarning(...lines) {\n  lines = lines.map(line => {\n    return line.replace(/`[^`]+`/g, i => pico.yellow(i.slice(1, -1)))\n  })\n  process.stderr.write(pico.red(lines.join('\\n')) + '\\n')\n}\n\nexport default function showError(...lines) {\n  showWarning(...lines)\n  let err = new Error('show error')\n  err.own = true\n  return err\n}\n"
  },
  {
    "path": "lib/show-help.js",
    "content": "import pico from 'picocolors'\n\nlet b = pico.bold\nlet y = pico.yellow\nlet g = pico.green\n\nfunction print(...lines) {\n  process.stdout.write(lines.join('\\n') + '\\n')\n}\n\nexport default function showHelp() {\n  print(\n    b('Usage: ') + 'npx ssdeploy ' + g('COMMAND') + y(' [OPTION]'),\n    'Deploy simple websites with Google Cloud and Cloudflare',\n    '',\n    b('Commands:'),\n    '  ' + g('init') + '        Create .github/workflow/deploy.yml',\n    '  ' + g('deploy') + '      Deploy website to the cloud',\n    '  ' + g('preview') + ' ' + y('PR') + '  Deploy preview',\n    '  ' + g('close') + ' ' + y('PR') + '    Clean files from preview',\n    '  ' + g('run') + '         Run Docker image locally',\n    '  ' + g('shell') + '       Run shell inside Docker image',\n    '  ' + g('purge') + '       Clean CDN cache',\n    '  ' + g('changed') + '     Check changes between dist/ and website',\n    '  ' + g('sign') + ' ' + y('FILE') + '   Sign security.txt',\n    '',\n    b('Arguments:'),\n    '  ' + y('--verbose') + '   Show debug information',\n    '',\n    b('Examples:'),\n    '  npx ssdeploy deploy',\n    '  npx ssdeploy run',\n    '  npx ssdeploy sign ./src/well-known/security.txt'\n  )\n}\n"
  },
  {
    "path": "lib/show-spinner.js",
    "content": "import pico from 'picocolors'\n\nexport default function showSpinner(text) {\n  process.stdout.write(pico.gray('- ') + text + '\\n')\n  let finished = false\n  return {\n    succeed(newText = text) {\n      if (!finished) {\n        finished = true\n        process.stdout.write(pico.green('✔ ') + newText + '\\n')\n      }\n    },\n    fail(newText = text) {\n      if (!finished) {\n        finished = true\n        process.stdout.write(pico.red('✖ ') + newText + '\\n')\n      }\n    }\n  }\n}\n\nexport async function wrap(text, cb) {\n  let spinner = showSpinner(text)\n  try {\n    let result = await cb(spinner)\n    spinner.succeed()\n    return result\n  } catch (e) {\n    spinner.fail()\n    throw e\n  }\n}\n"
  },
  {
    "path": "lib/show-version.js",
    "content": "import { readFile } from 'fs/promises'\nimport { join } from 'path'\nimport pico from 'picocolors'\n\nimport { ROOT } from './dirs.js'\n\nexport default async function showVersion() {\n  let packagePath = join(ROOT, 'package.json')\n  let packageJson = await readFile(packagePath)\n  let { version } = JSON.parse(packageJson)\n  process.stdout.write(`ssdeploy ${pico.bold(version)}\\n`)\n}\n"
  },
  {
    "path": "lib/sign.js",
    "content": "import { writeFile, unlink, rename, readFile } from 'fs/promises'\nimport { promisify } from 'util'\nimport child from 'child_process'\n\nlet exec = promisify(child.exec)\n\nexport default async function sign(path) {\n  let content = await readFile(path)\n  let cleared = content\n    .toString()\n    .replace(/-----BEGIN PGP SIGNED MESSAGE-----\\nHash:[^\\n]+\\n\\n/, '')\n    .replace(/-----BEGIN PGP SIGNATURE-----\\n\\n[\\W\\w]+$/m, '')\n  await writeFile(path, cleared)\n  await exec(`gpg --clearsign ${path}`)\n  await unlink(path)\n  await rename(path + '.asc', path)\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"ssdeploy\",\n  \"version\": \"0.9.3\",\n  \"description\": \"Netlify replacement to deploy simple websites with better flexibility and speed and without vendor lock-in\",\n  \"keywords\": [\n    \"deploy\",\n    \"google cloud\",\n    \"cloudflare\",\n    \"netlify\"\n  ],\n  \"bin\": \"./bin.js\",\n  \"type\": \"module\",\n  \"engines\": {\n    \"node\": \">=14.0.0\"\n  },\n  \"scripts\": {\n    \"test\": \"eslint .\"\n  },\n  \"dependencies\": {\n    \"bytes\": \"^3.1.2\",\n    \"dotenv\": \"^16.0.3\",\n    \"folder-hash\": \"^4.0.4\",\n    \"hasha\": \"^5.2.2\",\n    \"open\": \"^8.4.2\",\n    \"picocolors\": \"^1.0.0\"\n  },\n  \"author\": \"Andrey Sitnik <andrey@sitnik.ru>\",\n  \"license\": \"MIT\",\n  \"repository\": \"ai/ssdeploy\",\n  \"devDependencies\": {\n    \"@logux/eslint-config\": \"^49.0.0\",\n    \"clean-publish\": \"^4.1.1\",\n    \"eslint\": \"^8.36.0\",\n    \"eslint-config-standard\": \"^17.0.0\",\n    \"eslint-plugin-import\": \"^2.27.5\",\n    \"eslint-plugin-n\": \"^15.6.1\",\n    \"eslint-plugin-prefer-let\": \"^3.0.1\",\n    \"eslint-plugin-promise\": \"^6.1.1\"\n  },\n  \"eslintConfig\": {\n    \"extends\": \"@logux/eslint-config\",\n    \"rules\": {\n      \"no-console\": \"off\"\n    }\n  },\n  \"eslintIgnore\": [\n    \"lib/dirs.js\"\n  ],\n  \"prettier\": {\n    \"arrowParens\": \"avoid\",\n    \"jsxSingleQuote\": false,\n    \"quoteProps\": \"consistent\",\n    \"semi\": false,\n    \"singleQuote\": true,\n    \"trailingComma\": \"none\"\n  },\n  \"clean-publish\": {\n    \"cleanDocs\": true\n  }\n}\n"
  },
  {
    "path": "purge.js",
    "content": "#!/usr/bin/env node\n\nimport callCloudflare from './lib/call-cloudflare.js'\n\ncallCloudflare('purge_cache', { purge_everything: true }).catch(e => {\n  process.stderr.write(e.stack + '\\n')\n  process.exit(1)\n})\n"
  }
]