NNO CLI Service
Documentation for NNO CLI Service
Date: 2026-03-30
Status: Detailed Design
Parent: System Architecture
Package: services/cli
Overview
The NNO CLI Service is a Cloudflare Worker that acts as the backend for both the nno CLI binary and the NNO Portal's platform management actions.
Responsibility Boundary
| Responsibility | Description |
|---|---|
| GitHub repo management | Creates per-platform repos from the nno-stack-starter template via the GitHub App. Manages features.config.ts, package.json, env.config.ts, and auth.config.ts file commits using the Git Data API (multi-file atomic commits). |
| Feature config commits | When a feature is activated/deactivated (Phase 2), commits updated features.config.ts to the platform repo. A single commit covers all changed files to avoid multiple CF Pages builds. |
| CF Pages build trigger | A git push to the platform repo triggers a GitHub Actions workflow (deploy.yml) included in the nno-stack-starter template. The workflow runs pnpm build and wrangler pages deploy to push the built shell to CF Pages. This mirrors how apps/console deploys and avoids requiring a CF dashboard OAuth connection per platform. Direct CF Pages API calls are reserved for manual/emergency deploys only. |
| Build status tracking | Polls and surfaces CF Pages deployment status back to callers via GET /platforms/\{id\}/build-status. |
| GitHub App token caching | Caches short-lived GitHub App installation tokens in KV (TOKENS binding) with a 50-minute TTL to avoid re-authenticating on every request. |
The NNO CLI Service is not directly called by end users. It is called by:
nno deployCLI command- NNO Provisioning Service (after feature infrastructure is ready)
- NNO Portal (on admin actions)
Phase 1 Status
| Capability | Status | Notes |
|---|---|---|
POST /platforms — Platform repo creation via GitHub API | ✅ Live | Creates repo from nno-stack-starter template; caches in KV |
GET /platforms/\{platformId\}/repo — Repo info | ✅ Live | Reads from KV cache |
POST /platforms/\{platformId\}/features/activate — Feature activation | ✅ Live | Reads/writes features.config.ts via GitHub Contents API |
POST /platforms/\{platformId\}/features/deactivate — Feature deactivation | ✅ Live | Soft-disables feature in features.config.ts |
POST /platforms/\{platformId\}/deploy — Manual CF Pages rebuild | ✅ Live | Direct CF Pages API trigger |
GET /platforms/\{platformId\}/build-status — Build status | ✅ Live | Polls CF Pages API |
POST /platforms/\{platformId\}/secrets — Provision CF Pages env secrets | ✅ Live | Patches CF Pages project via CF API |
| GitHub App JWT token caching (KV, 50-min TTL) | ✅ Live | |
| Feature Activation Flow (end-to-end: Portal → Gateway → Provisioning → CLI Service → GitHub → CF Pages) | 🟡 Partial | Per-service infra provisioning live; shell rebuild flow integrated |
1. GitHub App Setup [Phase 1]
The NNO CLI Service authenticates to GitHub using a GitHub App (not a personal access token), providing:
- Fine-grained permissions scoped to the NNO organisation
- Per-installation tokens (short-lived, auto-refreshed)
- Audit trail in the GitHub App installation logs
Required GitHub App Permissions
| Permission | Level | Reason |
|---|---|---|
Contents | Read & Write | Create/update files in platform repos |
Metadata | Read | Repo info lookup |
Administration | Read & Write | Create repos from template |
Workflows | Read & Write | Required only if platform repos use GitHub Actions CI |
Credential Storage
GitHub App credentials are stored as Wrangler secrets on the NNO CLI Service Worker:
| Secret | Description |
|---|---|
GITHUB_APP_ID | GitHub App ID (numeric string) |
GITHUB_APP_PRIVATE_KEY | PEM-encoded RSA private key |
GITHUB_APP_INSTALLATION_ID | GitHub App installation ID for the NNO organisation |
TEMPLATE_REPO_FULL_NAME | Full path of the template repo (e.g., neutrino-io/nno-stack-starter) |
Token Generation
import { createAppAuth } from '@octokit/auth-app';
import { Octokit } from '@octokit/rest';
async function getOctokit(env: Env): Promise<Octokit> {
const auth = createAppAuth({
appId: env.GITHUB_APP_ID,
privateKey: env.GITHUB_APP_PRIVATE_KEY,
});
// Get installation ID for the NNO org
const { token } = await auth({
type: 'installation',
installationId: await getInstallationId(env),
});
return new Octokit({ auth: token });
}Tokens are cached in KV with a TTL of 50 minutes (GitHub App tokens expire at 60 minutes).
Note: GitHub App JWT authentication is a Phase 2 feature. The current implementation uses a static
GITHUB_APP_PRIVATE_KEYsecret configured viawrangler secret put.
2. Platform Repo Structure [Phase 1]
Every platform gets its own GitHub repository in the NNO organisation:
neutrino-platform/
└── {platform-id}-platform-console/ ← one repo per platform
├── src/
│ ├── config/
│ │ ├── features.config.ts ← NNO-managed: active feature list
│ │ ├── auth.config.ts ← NNO-managed: auth service URL
│ │ └── env.config.ts ← NNO-managed: per-tenant env vars
│ └── ... ← shell source (from template, not modified)
├── package.json ← NNO-managed: feature package deps
├── pnpm-lock.yaml ← regenerated on feature changes
├── wrangler.toml ← CF Pages config (NNO-managed)
└── .github/
└── workflows/
└── ci.yml ← optional CI (from template)Files Managed by NNO CLI Service
| File | When Updated |
|---|---|
src/config/features.config.ts | Feature activated / deactivated |
package.json | Feature activated / deactivated |
pnpm-lock.yaml | Feature activated / deactivated (regenerated via pnpm) |
src/config/auth.config.ts | Platform provisioned / auth URL changed |
src/config/env.config.ts | Service URL added/removed (on feature activation) |
wrangler.toml | Platform created / CF Pages project config change |
All other files (shell source, vite.config.ts, components, etc.) come from the template and are not modified after repo creation. Platform source changes are out of scope for NNO CLI Service.
Template Repo (nno-stack-starter)
The nno-stack-starter template serves as the canonical template for per-stack repos. The CLI Service creates new stack repos using GitHub's Create repository from template API, then applies the initial stack-specific config overrides.
3. API Endpoints [Phase 1]
All endpoints require Authorization: Bearer \{nno-session-token\} and a valid NNO platform-admin or NNO-operator role.
3.1 POST /platforms — Create Platform Repo
Called by NNO Provisioning after a platform is registered in the Registry.
Request
{
"platformId": "k3m9p2xw7q",
"displayName": "AcmeCorp",
"planTier": "growth"
}Response — 201 Created
{
"platformId": "k3m9p2xw7q",
"repoFullName": "neutrino-platform/k3m9p2xw7q-platform-console",
"status": "created"
}Returns status: "exists" (200) if the platform repo was already created (idempotent).
What it does (Phase 1):
- Creates repo from template via GitHub API (
neutrino-io/nno-stack-starter) - Stores repo record in KV (
repo:\{platformId\}) - Returns
\{ platformId, repoFullName, status \}
3.2 POST /platforms/\{platformId\}/features/activate — Activate Feature
Called by NNO Provisioning after feature Worker + D1 are ready.
Request
{
"featureId": "analytics",
"version": "1.2.0",
"config": {}
}Response
{
"status": "activated",
"featureId": "analytics"
}What it does:
- Validates the platform repo exists in KV
- Reads
features.config.tsfrom the platform repo via GitHub Contents API - Adds the feature entry (
featureId,enabled: true,version, optionalconfig) - Commits the updated file back to the platform repo
- Returns
\{ status: 'activated', featureId, version \}
3.3 POST /platforms/\{platformId\}/features/deactivate — Deactivate Feature
Request
{
"featureId": "analytics"
}Phase 2 schema additions:
packageName(e.g.,"@neutrino-io/ui-analytics") andtenantIdwill be added to the deactivate request body when the full commit-based workflow is implemented.
What it does:
- Fetches current
features.config.tsandpackage.json - Sets
enabled: falseon the feature entry (does not delete — preserves config history) - Removes
serviceUrlfromenv.config.ts - Commits:
"feat: deactivate analytics for tenant r8n4t6y1z5" - Triggers CF Pages rebuild
Soft disable vs. hard remove: Features are soft-disabled (
enabled: false) rather than removed from the config. Hard removal is only done on platform deprovision. This preserves config history and makes re-activation cheaper (no regeneration needed, just flipenabled: true).
3.4 POST /platforms/\{platformId\}/deploy — Trigger Manual Rebuild
Called by nno deploy CLI command or NNO Portal.
Request
{
"environment": "stg",
"reason": "Manual deploy triggered by operator"
}Response
{
"deploymentId": "01JK8M2P3N4Q5R6S7T8U9V0W1X",
"url": "https://k3m9p2xw7q-console.pages.dev"
}What it does:
- Calls Cloudflare Pages API:
POST /accounts/\{accountId\}/pages/projects/\{projectName\}/deployments - Returns the CF Pages deployment ID and preview URL
3.5 GET /platforms/\{platformId\}/build-status — Build Status
Response
{
"platformId": "k3m9p2xw7q",
"cfPagesProjectName": "k3m9p2xw7q-console",
"latestDeployment": {
"id": "01JK8M2P3N4Q5R6S7T8U9V0W1X",
"status": "active",
"url": "https://k3m9p2xw7q-console.pages.dev",
"createdOn": "2026-02-22T10:34:00Z"
}
}latestDeployment is null if no deployments exist yet.
CF Pages deployment status values: queued → in_progress → active | failure | canceled
3.6 GET /platforms/\{platformId\}/repo — Repo Info
Returns the current state of the platform repo (URL, branch, last commit, installed features).
3.7 POST /platforms/\{platformId\}/secrets — Provision CF Pages Env Secrets
Called by NNO Provisioning after infrastructure secrets are ready (e.g., D1 binding IDs, Worker URLs).
Request
{
"secrets": {
"BILLING_WORKER_URL": "https://billing.nno.internal",
"ANALYTICS_DB_ID": "abc123"
}
}Response
{
"platformId": "k3m9p2xw7q",
"results": {
"BILLING_WORKER_URL": "ok",
"ANALYTICS_DB_ID": "ok"
}
}What it does:
Sets each key as a CF Pages environment variable (secret_text type) via PATCH /accounts/\{accountId\}/pages/projects/\{projectName\}. Failures per-key are reported individually — partial success is possible.
4. Feature Activation Flow (End-to-End) [Phase 1]
Admin activates "analytics" on AcmeCorp platform
│
▼
NNO Portal → POST /api/features/activate
│
▼
NNO Gateway → NNO Provisioning Service
1. Validates package exists in Marketplace
2. Validates feature not already active
3. Creates PROVISION_ACTIVATE job
│
▼
Provisioning state machine runs:
Step 1: Create CF Worker (k3m9p2xw7q-r8n4t6y1z5-analytics-prod)
Step 2: Create D1 database (k3m9p2xw7q-r8n4t6y1z5-analytics-db-prod)
Step 3: Run D1 migrations
Step 4: Set Worker secrets
Step 5: Bind D1 to Worker
│
▼ (provisioning complete)
NNO CLI Service: POST /platforms/k3m9p2xw7q/features/activate
Step 6: Fetch repo files via GitHub API
Step 7: Generate updated features.config.ts
Step 8: Update package.json + env.config.ts
Step 9: Commit + push to GitHub
│
▼ (commit pushed)
Cloudflare Pages webhook fires (GitHub → CF Pages integration)
Step 10: CF Pages builds new shell
Step 11: Deploys to k3m9p2xw7q-console.pages.dev
│
▼
NNO Registry: mark feature_activation as ACTIVE
NNO CLI Service: returns buildId to Provisioning
Provisioning: marks job as COMPLETED
│
▼
Admin sees success in NNO Portal
End user: new feature routes appear on next login5. Commit Strategy [Phase 1]
All commits are made under the nno-bot GitHub App identity. The commit message convention follows Conventional Commits:
| Action | Commit Message Format |
|---|---|
| Platform created | chore: initialise platform \{platformId\} |
| Feature activated | feat: activate \{featureId\} for \{tenantId\} |
| Feature deactivated | feat: deactivate \{featureId\} for \{tenantId\} |
| Auth URL updated | fix: update auth service URL |
| Manual config sync | chore: sync platform config from registry |
| Package version bump | chore: update \{packageName\} to \{version\} |
Batching
If multiple features are activated/deactivated in the same request (bulk operation), the CLI Service batches all file changes into a single commit to avoid triggering multiple CF Pages rebuilds:
feat: activate [analytics, billing, crm] for r8n4t6y1z56. CF Pages Build Trigger [Phase 1]
Platform repos created from nno-stack-starter include a .github/workflows/deploy.yml that runs on push to main/develop. The workflow:
- Installs dependencies (
pnpm install) - Builds the console shell (
pnpm build) - Deploys to CF Pages via
wrangler pages deploy ./dist --project-name=\{platformId\}-\{env\}-portal
This is the same pattern used by apps/console (the NNO operator console) and is consistent with how all CF Pages projects in this monorepo deploy. It requires CF_API_TOKEN and CF_ACCOUNT_ID to be set as neutrino-io org-level GitHub secrets — set once, inherited by every platform repo created from the template. No CF dashboard OAuth connection is required per project.
The NNO CLI Service does not call the CF Pages API directly to trigger builds in the normal activation flow — a git push to the platform repo triggers the GitHub Actions deploy workflow automatically.
Direct CF Pages API trigger (POST /platforms/\{id\}/deploy) is reserved for:
- Manual deploys (
nno deploycommand) - Emergency redeployment without a repo change
CF Pages API Trigger
async function triggerCFPagesBuild(
env: Env,
projectName: string,
branch = 'main'
): Promise<CFDeployment> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/pages/projects/${projectName}/deployments`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${env.CF_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ branch }),
}
);
const data = await response.json<{ result: CFDeployment }>();
return data.result;
}CF Pages Project Naming
| Environment | CF Pages Project Name Pattern | Branch |
|---|---|---|
| Production | \{platformId\}-console | main |
| Staging | \{platformId\}-console | develop |
| Preview | \{platformId\}-console | any |
Note: CF Pages uses a single project per platform (
\{platformId\}-console) with separate environments (production, preview) configured within the same project. There is no-\{env\}suffix in the project name.
7. GitHub File Management [Phase 1]
All file reads and writes use the GitHub Contents API:
// Read a file
async function readFile(
octokit: Octokit,
org: string,
repo: string,
path: string
): Promise<{ content: string; sha: string }> {
const { data } = await octokit.repos.getContent({ owner: org, repo, path });
if (Array.isArray(data)) throw new Error(`${path} is a directory`);
return {
content: Buffer.from(data.content, 'base64').toString('utf-8'),
sha: data.sha,
};
}
// Update a file (requires current SHA for optimistic locking)
async function updateFile(
octokit: Octokit,
org: string,
repo: string,
path: string,
content: string,
message: string,
currentSha: string
): Promise<string> {
const { data } = await octokit.repos.createOrUpdateFileContents({
owner: org,
repo,
path,
message,
content: Buffer.from(content).toString('base64'),
sha: currentSha,
});
return data.commit.sha;
}Multi-File Commits (Phase 2)
GitHub's Contents API is file-by-file. For multi-file commits (e.g., updating features.config.ts + package.json + env.config.ts atomically), the NNO CLI Service uses the Git Data API (trees + blobs):
1. GET /repos/{owner}/{repo}/git/ref/heads/main → get current HEAD SHA
2. GET /repos/{owner}/{repo}/git/commits/{sha} → get tree SHA
3. POST /repos/{owner}/{repo}/git/blobs → upload each file blob
4. POST /repos/{owner}/{repo}/git/trees → create new tree with all blobs
5. POST /repos/{owner}/{repo}/git/commits → create commit pointing to new tree
6. PATCH /repos/{owner}/{repo}/git/refs/heads/main → advance HEAD to new commitThis produces a single atomic commit regardless of how many files changed.
8. Code Generation: features.config.ts [Phase 1]
The NNO CLI Service generates the contents of features.config.ts by reading the platform's active feature list from the Registry and rendering the TypeScript file programmatically.
See Shell Feature Config specification for the full schema and generation format.
9. Wrangler Configuration
# services/cli/wrangler.toml
name = "nno-k3m9p2xw7q-cli-service"
main = "src/index.ts"
compatibility_date = "2024-09-13"
compatibility_flags = ["nodejs_compat"]
# Production (default — no --env flag)
[[kv_namespaces]]
binding = "TOKENS"
id = "f2ca54f53cd84925bb7ffd5c811779a6"
[[analytics_engine_datasets]]
binding = "NNO_METRICS"
dataset = "nno_metrics"
[env.stg]
name = "nno-k3m9p2xw7q-cli-service-stg"
[[env.stg.kv_namespaces]]
binding = "TOKENS"
id = "72610903da4c4a2f9ddb80cc4e4517ae"
[[env.stg.analytics_engine_datasets]]
binding = "NNO_METRICS"
dataset = "nno_metrics"Secrets (per environment)
| Secret | Description |
|---|---|
GITHUB_APP_ID | GitHub App numeric ID |
GITHUB_APP_PRIVATE_KEY | PEM private key for GitHub App auth |
GITHUB_APP_INSTALLATION_ID | GitHub App installation ID for the NNO organisation |
TEMPLATE_REPO_FULL_NAME | Full path of the template repo (e.g., neutrino-io/nno-stack-starter) |
CF_API_TOKEN | Cloudflare API token (Pages deploy scope) |
CF_ACCOUNT_ID | Cloudflare account ID |
NNO_REGISTRY_URL | Internal URL of NNO Registry Worker |
NNO_INTERNAL_API_KEY | For service-to-service calls to Registry |
AUTH_API_KEY | For authenticating incoming CLI and portal requests |
CORS_ORIGINS | Comma-separated allowed origins for the CLI Service |
GITHUB_PAT | Personal access token used as a fallback when the GitHub App token has insufficient permissions (e.g., administration:write for repo creation or access to newly-created platform repos not yet covered by the App installation scope) |
10. Error Handling
| Error | HTTP Status | Behaviour |
|---|---|---|
| GitHub repo already exists | 422 | Skip creation, return existing repo URL |
| GitHub rate limit hit | 429 | Retry with Retry-After header delay |
| GitHub App token expired | 401 | Refresh token from KV, retry once |
| CF Pages project not found | 404 | Return error, provisioning rolls back |
| File SHA conflict (concurrent write) | 409 | Fetch new SHA, retry write once |
| Template repo inaccessible | 500 | Return actionable error to caller |
All errors are logged to the Registry's audit_log table with action = 'CLI_SERVICE_ERROR'.
Status: Detailed design — ready for implementation
Implementation target: services/cli/
Related: System Architecture §14.E · NNO Provisioning · Shell Feature Config