installkite

Pattern no. 2, the flight manual

installkite docs

Every command and flag, the config file, the runtime script, the service worker, the doctor rules, the Node API and recipes for each framework and host.

An open pattern envelope with numbered paper parts sliding out: the docsAn open pattern envelope with numbered paper parts sliding out: the docs

Install

Nothing to install for a one off run: npx downloads the CLI. For CI and repeat builds add it as a dev dependency. Node 18 or newer.

Terminal
npx installkite init

# or keep it in the project for CI builds
npm install --save-dev installkite

The runtime script also ships on the CDN, pinned to a version:

index.html
<script src="https://cdn.jsdelivr.net/npm/installkite@0.1.0/dist/installkite.min.js" data-sw="/sw.js" data-install="auto" defer></script>

Quick start

  1. Run init in the site folder. It finds your pages, your logo and your colors and asks a few questions. Add --yes to accept the detected values.
  2. Check it. npx installkite doctor lists every installability and offline rule with a fix for each failure.
  3. Deploy over HTTPS. GitHub Pages, Netlify, Vercel and Cloudflare Pages all qualify. Open the site and the browser offers to install it.
  4. After each site build run npx installkite build (frameworks get it added to the build script) so the service worker precaches the new files.
Terminal
npx installkite init --dry-run   # preview every change as a diff
npx installkite init --yes       # write it
npx installkite doctor           # audit

installkite init

Scans the project, then writes the manifest, icons, offline page, runtime, service worker and config, and injects the head tags into your HTML. It is idempotent: the tags live between <!-- installkite:start --> and <!-- installkite:end -->, a second run updates that block in place, and tags your page already has (a favicon, a manifest link, a theme color) are kept and not duplicated.

FlagWhat it does
--root <dir>The deployed folder that gets precached. Detected: the folder with index.html, dist for Vite and Astro, out for Next.js, public for Laravel.
--public <dir>Where generated files are written, so a framework copies them into every build. public for Vite, Next.js and Astro.
--base <path>The URL path the site is served under, like /my-repo/ on GitHub Pages. Detected from the git remote and framework config.
--relativeWrite relative URLs in head tags (./, ../) so the site works from any folder.
--icon <file>Source image, PNG or SVG, ideally 512 pixels or larger. Without one, a monogram of the initials is drawn.
--name, --short-name, --descriptionManifest text. The short name is the home screen label, 12 characters or fewer.
--theme-color, --theme-color-dark, --background-colorAny CSS color. The theme color defaults to the page meta tag, then to the dominant color of the icon.
--display <mode>standalone (default), fullscreen, minimal-ui or browser.
--start-url, --scope, --lang, --orientationManifest fields. start_url and scope default to ./.
--cdnLink installkite.js from jsDelivr instead of copying it into the site.
--no-runtimeLeave out installkite.js and register the service worker yourself.
--no-injectDo not edit HTML files. The tags to paste are printed instead.
--no-install-button, --no-update-toast, --no-offline-indicatorTurn off parts of the runtime interface.
--no-offline-page, --no-screenshots, --no-shortcutsSkip the offline page, the placeholder screenshots or the shortcuts taken from your navigation.
--no-postbuildDo not append npx installkite build to the package.json build script.
-y, --yesAccept detected values without questions. Also the behavior without a terminal.
-n, --dry-runPrint a unified diff of every text file that would change. Nothing is written.
npx installkite init --dry-run
$ npx installkite init --dry-run
  create  icons/icon-48.png           905 B  icon
  create  icons/icon-72.png          1.4 kB  icon
  create  icons/icon-96.png          1.9 kB  icon
  create  icons/icon-128.png         2.6 kB  icon
  create  icons/icon-144.png         3.0 kB  icon
  create  icons/icon-152.png         3.0 kB  icon
  ...

--- a/about.html
+++ b/about.html
@@ -6,6 +6,17 @@
   <title>About | Acme Bakery</title>
   <meta name="description" content="Bread, cakes and coffee, baked every morning.">
   <link rel="stylesheet" href="style.css">
+  <!-- installkite:start -->
+  <link rel="manifest" href="/manifest.webmanifest">
+  <meta name="theme-color" content="#e8590d">
+  <link rel="icon" href="/favicon.ico" sizes="48x48">
+  <link rel="icon" href="/icons/icon.svg" type="image/svg+xml">
+  <link rel="apple-touch-icon" href="/apple-touch-icon.png">
+  <meta name="mobile-web-app-capable" content="yes">
+  <meta name="apple-mobile-web-app-title" content="Acme Bakery">
+  <meta name="apple-mobile-web-app-status-bar-style" content="default">
+  <script src="/installkite.js" data-sw="/sw.js" data-install="auto" data-offline="badge" defer></script>
+  <!-- installkite:end -->
 </head>
 <body>
   <header>

installkite build

Run it after every site build. It hashes the deployed folder again, rewrites sw.js there with the new precache list, and injects the head tags into pages a site generator rebuilt without them. Browsers see a changed worker, download only the files whose hash changed and show the update toast.

package.json
# package.json
"scripts": {
  "build": "vite build && npx installkite build"
}

--check writes nothing and exits with code 1 when sw.js is out of date, which makes a cheap CI guard:

.github/workflows/ci.yml
- run: npm run build
- run: npx installkite build --check

installkite doctor

Audits the files on disk (served as a static host would, under your base path) or a live site with --url https://example.com/. Exits with code 1 when a rule fails with an error, and with --strict also on warnings. --json prints the report for scripts.

RuleChecksLevel
httpsThe site is HTTPS or localhost (live), or the configured production URL is HTTPS (files).error
page, viewportThe start page loads; it has a viewport meta tag.error, warn
manifest-link, manifest-fetch, manifest-typeA manifest is linked, loads, is valid JSON and is served as JSON.error, warn
manifest-name, short-nameA name exists; the label fits in 12 characters.error, warn
displaystandalone, fullscreen or minimal-ui (or a display_override).error
start-url, scope, start-url-origin, start-url-reachablestart_url is set, same origin, inside scope and loads.error
id, descriptionStable app id; a description.info
theme_color, background_colorPresent and valid CSS colors.warn, error
icons, icon-file, icon-size192 and 512 pixel icons; every file loads; real sizes match the declared ones.error, warn
maskable, maskable-opaque, purposeA maskable icon exists, is full bleed, and is not mixed as "any maskable".warn
screenshotsScreenshots exist and are no longer the installkite placeholders.info, warn
theme-color-meta, apple-touch-icon, faviconHead tags: theme color, a 180 pixel opaque apple-touch-icon, a favicon.info, warn
sw-registered, sw-fetch, sw-scope, sw-fetch-handlerA worker is registered, loads as JavaScript, controls start_url and handles fetch.error
offline-page, start-url-precachedThe offline page exists and is precached; the start page opens offline.warn
precache-freshFiles on disk: the precache list matches the current file hashes.error
npx installkite doctor --json (excerpt)
{
  "url": "https://site.installkite.local/",
  "mode": "local",
  "checks": [
    {
      "id": "https",
      "title": "Served over HTTPS",
      "status": "info",
      "message": "checked when you run doctor --url against the live site",
      "fix": "Deploy over HTTPS (localhost also works while developing)."
    },
    {
      "id": "viewport",
      "title": "Viewport meta tag",
      "status": "pass",
      "message": "the page sets a viewport"
    },
    {
      "id": "manifest-link",
      "title": "Page links a web app manifest",
      "status": "pass",
      "message": "links /manifest.webmanifest"
    },
    "...": "one entry per rule"
  ],
  "errors": 0,
  "warnings": 1,
  "installable": true,
  "offlineReady": true
}

icons and remove

installkite icons <image> writes only the icon set, for projects that already have a manifest. --out sets the folder, --background the color behind maskable icons and the apple-touch-icon, --screenshots adds placeholder screenshots.

Terminal
npx installkite icons brand/logo.svg --out public --background "#0b1426"

installkite remove takes the marked block out of every HTML file. Generated files stay where they are.

What gets written

FileWhat it is
manifest.webmanifestid, name, short_name, description, lang, start_url, scope, display, theme_color, background_color, icons, shortcuts and screenshots.
icons/icon-48.png to icon-512.pngTen sizes with purpose any, transparency kept.
icons/maskable-192.png, maskable-512.pngFull bleed background, the artwork scaled so its farthest visible pixel sits inside the 40% radius safe zone.
icons/icon.svgThe SVG source, used as a scalable favicon, when you started from SVG.
apple-touch-icon.png180 by 180, opaque, padded for transparent artwork.
favicon.ico, favicon-16x16.png, favicon-32x32.pngThe ICO holds 16, 32 and 48 pixel PNG images.
icons/screenshot-wide.png, screenshot-narrow.pngPlaceholders for the richer install dialog. Replace them with real captures; doctor reminds you.
offline.htmlSelf contained offline page in your colors, with the icon inlined and links to saved pages.
installkite.jsThe runtime, about 4.4 kB gzipped.
sw.jsThe service worker: one settings line with the precache list, then the strategies.
installkite.config.jsonWhat init decided, so build and doctor run without questions.

Config file

installkite.config.json sits next to your package.json. Edit it and run init --yes again to apply changes; a JSON schema at config.schema.json gives editors completion.

installkite.config.json
{
  "$schema": "https://kwy404.github.io/installkite/config.schema.json",
  "root": ".",
  "publicDir": ".",
  "base": "/",
  "icon": "logo.svg",
  "iconsDir": "icons",
  "html": [
    "**/*.html"
  ],
  "app": {
    "name": "Acme Bakery",
    "display": "standalone",
    "themeColor": "#e8590d",
    "backgroundColor": "#ffffff",
    "startUrl": "./",
    "scope": "./",
    "screenshots": true,
    "description": "Bread, cakes and coffee, baked every morning.",
    "lang": "en",
    "shortcuts": [
      {
        "name": "Menu",
        "url": "./menu.html"
      },
      {
        "name": "About",
        "url": "./about.html"
      }
    ]
  },
  "runtime": {
    "mode": "local",
    "installButton": "auto",
    "updateToast": true,
    "offlineIndicator": true
  },
  "sw": {},
  "placeholders": {
    "icons/screenshot-wide.png": "02ac7523e15a",
    "icons/screenshot-narrow.png": "7698c19e98aa"
  }
}
KeyMeaning
rootThe deployed folder, precached by build.
publicDirWhere generated files are written.
base, relativeURL base path, or relative URLs per page.
urlYour production URL; doctor checks that it is HTTPS.
icon, iconsDirIcon source (or "monogram") and the icon folder.
htmlFiles or globs that get head tags. Plain sites use "**/*.html", so new pages are covered.
appManifest values: name, shortName, description, lang, display, themeColor, themeColorDark, backgroundColor, startUrl, scope, orientation, categories, shortcuts, screenshots.
runtimemode (local, cdn or none), installButton, updateToast, offlineIndicator.
swService worker options, listed below, plus include, exclude and maxFileSize for the precache.

Runtime script

One tag, configured with data attributes. It registers the worker once the page has loaded, so it never competes with your own resources.

index.html
<script src="/installkite.js" data-sw="/sw.js" data-install="auto" data-offline="badge" defer></script>
AttributeMeaning
data-swService worker URL. Default: sw.js next to installkite.js.
data-scopeRegistration scope. Default: the worker folder.
data-install="auto"A floating install button when the site can be installed. Dismissing it hides it for 14 days.
data-update="none"No "Refresh to update" toast (listen to the update event instead).
data-offline="badge"A small badge while the device is offline.
data-manualDo not register the worker; call installkite.register() yourself.

Any element with data-installkite-install becomes an install button: it stays hidden until the site can be installed and opens the prompt, or the iOS sheet, on click.

header.html
<button class="btn" data-installkite-install hidden>Install the app</button>

The interface follows your theme-color meta tag. Override it with CSS variables:

site.css
:root {
  --installkite-accent: #2b8ff0;      /* buttons */
  --installkite-on-accent: #ffffff;   /* text on buttons */
  --installkite-surface: #ffffff;     /* toast, sheet, button bar */
  --installkite-text: #14213d;
  --installkite-surface-dark: #18202f;
  --installkite-text-dark: #eef2f8;
}

Runtime API

app.js
// installkite.js from the script tag defines window.installkite.
// With a bundler: import installkite from 'installkite/runtime'; installkite.init();

installkite.on('installable', () => {
  document.querySelector('#install').hidden = false;
});

document.querySelector('#install').addEventListener('click', async () => {
  const outcome = await installkite.install(); // accepted, dismissed, instructions (iOS)
  console.log('install:', outcome);
});

installkite.on('installed', () => console.log('Welcome to the home screen'));
installkite.on('update', ({ apply }) => showMyBanner('New version', apply));
installkite.on('offline', () => document.body.classList.add('is-offline'));
installkite.on('online', () => document.body.classList.remove('is-offline'));

// Save pages for offline reading.
await installkite.cache(['/articles/one/', '/articles/two/']);
MemberWhat it does
init(options)Reads options and starts. The script tag build calls it for you with the data attributes. Options: sw, scope, install, update, offline, manual, snoozeDays, text.
register(url?, scope?)Registers the service worker, resolves to the registration or null.
install()Shows the browser prompt and resolves to accepted or dismissed; on iOS shows the sheet and resolves to instructions; otherwise unavailable.
update()Activates a waiting worker and reloads once it takes over.
cache(urls)Saves pages into the offline page cache, for "read later" buttons. Resolves to the number saved.
showIOSInstructions()Opens the Add to Home Screen sheet.
on(event, fn), off(event, fn)Events: ready, installable, installed, update, updated, offline, online, error. on returns an unsubscribe function. Each is also a DOM event named installkite:<event> on window.
canInstall, isInstalled, isIOS, online, registration, versionRead only state.

Every interface string can be translated through text:

i18n.js
installkite.init({
  text: {
    install: 'Instalar app',
    iosTitle: 'Adicione a Tela de Inicio',
    iosStep1: 'Toque em Compartilhar na barra',
    iosStep2: 'Toque em Adicionar a Tela de Inicio',
    iosStep3: 'Toque em Adicionar',
    update: 'Nova versao disponivel',
    refresh: 'Atualizar',
    offline: 'Voce esta offline',
    online: 'Conectado de novo',
  },
});

Service worker

The generated sw.js starts with one line of settings, then the strategies. Precached files are stored in installkite-precache-<version>, where the version is a hash of every file hash; runtime caches are installkite-pages, installkite-assets and installkite-images.

RequestStrategy
A precached file (no query string)Cache first from the precache. Install downloads each file once and copies unchanged ones from the previous version.
A page navigation inside the scopeNetwork first, raced against navigationTimeout. On failure or timeout: the cached page (also tried as /page/, /page/index.html, /page.html), then the offline page. Successful pages refresh the page cache. Navigation preload is used when the browser has it.
Scripts, styles, fonts, JSON, manifests (same origin or listed CDNs)Stale while revalidate, capped at assetMaxEntries.
ImagesCache first with imageMaxEntries and imageMaxAge. Opaque cross origin images are not cached.
Anything else: POST, range requests, other origins, excluded pathsNot handled; the browser fetches normally.
sw optionDefaultMeaning
offline"offline.html"The fallback page, or null.
navigationTimeout3000Milliseconds before a slow page falls back to the cache.
imageMaxEntries, imageMaxAge60, 2592000Image cache size and max age in seconds (30 days).
assetMaxEntries, pageMaxEntries120, 50Caps for the runtime caches.
exclude[]Path prefixes relative to the scope, or "/regex/" strings, never handled. For example "api/".
crossOriginfont hosts and CDNsOrigins whose scripts, styles and fonts are cached.
skipWaitingfalseActivate new versions at once instead of after "Refresh".
importScripts[]Your own worker code, for push or background sync handlers.
include, exclude, maxFileSizeHTML, CSS, JS, fonts, key icons; 2 MiBWhich built files are precached.
sw.js (first lines)
/* Service worker generated by installkite 0.1.0: https://kwy404.github.io/installkite/
 * Run "npx installkite build" after every site build to refresh the precache list. */
self.__INSTALLKITE__ = {
  "version": "8e1ebe2623",
  "precache": [
    ["about.html","ec810b41b7a8"],
    ["apple-touch-icon.png","821006243b6d"],
    ["favicon-32x32.png","4e48f2b8460a"],
    ["favicon.ico","5fc5d0cfdb3f"],
    /* 9 more files, each with its content hash */
  ]
};
/* the bundled strategies follow, about 7 kB */

Base paths and scope

The manifest uses relative URLs ("start_url": "./", "scope": "./"), so it works wherever it is served. The head tags need a base: / for a domain root, /repo/ for a GitHub Pages project site. init detects it from package.json homepage, a Vite or Astro base, a Next.js basePath, or a GitHub remote with Pages evidence (a Pages workflow, a docs/ site or the gh-pages package); a CNAME file means a custom domain at /. With --relative every page gets its own prefix such as ../.

Recipes

Plain HTML

Run init in the folder with index.html. Every page gets the tags, including pages you add later when you run build.

Terminal
cd my-site
npx installkite init --yes
npx installkite doctor

Vite

Generated files go to public/, the tags into the source index.html, and the build script gets npx installkite build so dist/sw.js precaches the hashed bundles. A base in vite.config is picked up.

Terminal
npx installkite init --yes
npm run build    # vite build && npx installkite build
vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  base: '/my-repo/', // GitHub Pages project site
});

Next.js (static export)

With output: 'export' the site builds to out/. init writes the files to public/, adds npx installkite build to the build script and prints the tags; put them in the root layout.

app/layout.tsx
import type { Metadata, Viewport } from 'next';
import Script from 'next/script';

export const viewport: Viewport = { themeColor: '#2b8ff0' };

export const metadata: Metadata = {
  manifest: '/manifest.webmanifest',
  icons: { icon: '/favicon.ico', apple: '/apple-touch-icon.png' },
  appleWebApp: { title: 'Acme', statusBarStyle: 'default' },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Script src="/installkite.js" data-sw="/sw.js" data-install="auto" strategy="afterInteractive" />
      </body>
    </html>
  );
}

Astro

Astro builds to dist/ and copies public/. Paste the printed tags into your base layout; base from astro.config.mjs is detected.

src/layouts/Base.astro
---
const { title } = Astro.props;
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>{title}</title>
    <link rel="manifest" href="/manifest.webmanifest" />
    <meta name="theme-color" content="#2b8ff0" />
    <link rel="icon" href="/favicon.ico" sizes="48x48" />
    <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
    <script is:inline src="/installkite.js" data-sw="/sw.js" data-install="auto" defer></script>
  </head>
  <body><slot /></body>
</html>

WordPress

Run init in your theme folder with --public ., upload the generated files to the site root (the worker must sit at /sw.js to control the whole site) and print the tags from wp_head. Exclude the admin and API from the worker.

functions.php
// functions.php
add_action('wp_head', function () {
  echo '<link rel="manifest" href="/manifest.webmanifest">' . "\n";
  echo '<meta name="theme-color" content="#2b8ff0">' . "\n";
  echo '<link rel="apple-touch-icon" href="/apple-touch-icon.png">' . "\n";
  echo '<script src="/installkite.js" data-sw="/sw.js" data-install="auto" defer></script>' . "\n";
});
installkite.config.json
"sw": { "exclude": ["wp-admin/", "wp-json/", "wp-login.php"] }

Laravel

Laravel serves public/, which init detects as both the root and the output folder. Put the tags in your main Blade layout and keep API and auth routes out of the worker.

resources/views/layouts/app.blade.php
<head>
    <meta charset="utf-8">
    <link rel="manifest" href="{{ asset('manifest.webmanifest') }}">
    <meta name="theme-color" content="#2b8ff0">
    <link rel="apple-touch-icon" href="{{ asset('apple-touch-icon.png') }}">
    <script src="{{ asset('installkite.js') }}" data-sw="{{ asset('sw.js') }}" data-install="auto" defer></script>
</head>
installkite.config.json
"sw": { "exclude": ["api/", "login", "logout", "sanctum/"] }

GitHub Pages

A project site lives at https://user.github.io/repo/, so every URL needs /repo/. init reads the repository from your git remote and writes the base for you; a CNAME file switches back to /. Turn on "Enforce HTTPS" in the Pages settings.

Terminal
npx installkite init --yes                # base detected from the git remote
npx installkite init --yes --base /repo/  # or set it yourself

Netlify

Run the build step after your site build and serve the manifest with the right type. Netlify already serves HTTPS.

netlify.toml
[build]
  command = "npm run build && npx installkite build"
  publish = "dist"

[[headers]]
  for = "/manifest.webmanifest"
  [headers.values]
    Content-Type = "application/manifest+json"

[[headers]]
  for = "/sw.js"
  [headers.values]
    Cache-Control = "no-cache"

Node API

Every piece of the CLI is exported, typed, for build tools and scripts.

build-pwa.ts
import { applyPlan, doctorLocal, generateIcons, loadIconSource, planBuild, planInit } from 'installkite';
import { readFileSync } from 'node:fs';

// The same as npx installkite init --yes, from a script.
const plan = planInit(process.cwd(), { name: 'Acme Bakery', themeColor: '#e8590c', postbuild: false });
for (const file of plan.files) if (file.diff) console.log(file.diff);
applyPlan(process.cwd(), plan.files);

// After a site build:
applyPlan(process.cwd(), planBuild(process.cwd()).files);

// Only the icons, in memory:
const source = loadIconSource(readFileSync('logo.svg'), 'logo.svg');
const { files } = generateIcons(source, { background: '#ffffff' });

const report = await doctorLocal(process.cwd());
if (!report.installable) process.exitCode = 1;
ExportWhat it does
planInit(cwd, answers), planBuild(cwd), applyPlan(cwd, files)The init and build pipelines. Plans are lists of files with their new content and a diff; nothing is written until applyPlan.
generateIcons(source, options)The icon set from loadIconSource(bytes). Returns PNG, ICO and SVG files.
maskableScale(radius), maskablePadding(size, radius), inSafeZone(x, y, size)The safe zone math.
generateManifest(options)A manifest object with fields in a readable order.
injectHead(html, options), removeHead(html)Idempotent head tag injection.
buildPrecache(root, options), generateServiceWorker(entries, options)The hashed precache list and sw.js.
createRouter(config, env)The service worker strategies against injected caches and fetch, for your own worker or tests.
doctorLocal(cwd), doctorRemote(url), runDoctor(context)The audit, on files or a live URL.
rasterizeSvg(svg, options), decodePng, encodePng, resize, encodeIcoThe image toolkit, pure JavaScript.

Limits

  • SVG sources are drawn by a built in rasterizer that covers what logos use: paths, shapes, gradients, strokes and dashes, transforms, opacity, clip paths, use, simple <style> rules and embedded PNG images. <text>, masks, filters and patterns are skipped with a warning: convert text to outlines before exporting.
  • Raster sources must be PNG for the CLI. The online generator also reads JPEG and WebP through the browser.
  • iOS has no install prompt, no theme color for the status bar in standalone mode beyond the status bar style, and clears storage of sites unused for weeks; installed apps keep theirs.
  • Firefox on desktop does not install sites. The service worker and offline page still work there.
  • Opaque responses (cross origin without CORS) are never cached, to avoid filling storage quotas.

Suggestions

Nothing matches. Skye looked behind every cloud.