Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
48f5051
fix(migrate): apply each D1 migration as a single transaction
decoded-cipher Aug 22, 2026
f55debc
feat(do): version Durable Object schemas and migrate them atomically
decoded-cipher Aug 22, 2026
d2cc81b
feat(deploy): rebuild wrangler.toml from upstream on every deploy
decoded-cipher Aug 22, 2026
00b7ee0
feat(web): add a single-owner Web Serial port composable
decoded-cipher Aug 22, 2026
daed5a6
feat(web): classify and buffer serial console output
decoded-cipher Aug 22, 2026
cb28033
feat(web): add the serial console
decoded-cipher Aug 22, 2026
d49d63d
feat(web): explain what the board is doing in plain language
decoded-cipher Aug 22, 2026
65c0cae
feat(db): give every project devices and scope variables to them
decoded-cipher Aug 22, 2026
9846b93
feat(worker): attribute incoming telemetry to a device
decoded-cipher Aug 22, 2026
b1e458d
feat(do): key project storage by device
decoded-cipher Aug 22, 2026
54c5073
feat(worker): make devices addressable end to end
decoded-cipher Aug 22, 2026
45585f6
feat(worker): let WebSocket boards identify themselves
decoded-cipher Aug 22, 2026
317c912
feat(web): list, rename and forget devices
decoded-cipher Aug 22, 2026
246264b
feat(worker): scope automations and control delivery to a device
decoded-cipher Aug 22, 2026
fc63db9
feat(web): flash a board from the browser
decoded-cipher Aug 22, 2026
9c76c95
feat(worker): flash published SDK examples through the browser
decoded-cipher Aug 22, 2026
58a719e
feat(worker): over-the-air updates by reconciliation
decoded-cipher Aug 22, 2026
3ba742d
feat(web): upload firmware and choose what each device should run
decoded-cipher Aug 22, 2026
1063926
feat(web): edit sketches in the browser
decoded-cipher Aug 22, 2026
cb84f38
fix(worker): stop device work leaking data and objects
decoded-cipher Aug 22, 2026
fb52966
feat(worker): export a project's data
decoded-cipher Aug 22, 2026
0f32ebd
feat(worker): cap how often a device can pull firmware
decoded-cipher Aug 22, 2026
dd0540d
test(do): prove fresh and migrated objects reach the same schema
decoded-cipher Aug 22, 2026
4d9527c
fix(do): budget recent history per device
decoded-cipher Aug 22, 2026
3360e28
feat(web): show whether a device is still reporting
decoded-cipher Aug 22, 2026
c624731
fix(mcp): address variables by device, not by bare key
decoded-cipher Aug 22, 2026
5982605
feat(worker): prune old firmware images
decoded-cipher Aug 22, 2026
81e1d40
fix(do): pin device-blind reads to the default device
decoded-cipher Aug 22, 2026
59c3c6a
feat(dashboards): let a dashboard name its device
decoded-cipher Aug 22, 2026
1e95c71
feat(web): pick which device a dashboard reads
decoded-cipher Aug 22, 2026
68a00ba
feat(worker): compile a sketch through the agent
decoded-cipher Aug 22, 2026
d7923f7
fix(devices): keep last_seen current on every device-facing path
decoded-cipher Aug 22, 2026
e381d5d
fix(build): keep compiled artifacts out of the project DO
decoded-cipher Aug 22, 2026
3e01297
fix(ota): reconcile with boards that were not listening when firmware…
decoded-cipher Aug 22, 2026
cfd7abb
Merge pull request #5 from decoded-cipher/feat/device-plane
decoded-cipher Aug 22, 2026
babe2f7
feat(build): stream the toolchain output while the build runs
decoded-cipher Aug 22, 2026
72cc3f1
refactor(device): drop the separate flash page
decoded-cipher Aug 22, 2026
bd529ef
refactor(firmware): make a saved build the only kind of firmware
decoded-cipher Aug 22, 2026
c1c2c41
fix(web): flash an ESP8266 at the address its image actually starts
decoded-cipher Aug 22, 2026
1adc3a9
feat(web): make the code page work like the Arduino IDE
decoded-cipher Aug 22, 2026
ca2c87d
Merge pull request #6 from decoded-cipher/refactor/device-simplification
decoded-cipher Aug 22, 2026
b93e26b
refactor(web): custom dropdowns everywhere, and drop the dead example…
decoded-cipher Aug 22, 2026
23dfa8b
Merge pull request #7 from decoded-cipher/refactor/device-simplification
decoded-cipher Aug 22, 2026
e9b69f4
feat(web): tell people what an agent is when a build needs one
decoded-cipher Aug 22, 2026
d253d7c
Merge pull request #8 from decoded-cipher/feat/agent-discoverability
decoded-cipher Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- 🧠 **Native MCP server** — an owner-gated Model Context Protocol endpoint with a Claude connector for AI clients (off by default).
- 👥 **Multi-user** — owner / admin / member roles, email invites, and social sign-in (Google, GitHub).
- 📝 **Audit log** — every privileged action recorded and paginated in the UI.
- 🔧 **Write, build and flash from the browser** — a code editor, serial monitor and Web Serial flasher in one page; the [nodrix agent](https://github.com/decoded-cipher/nodrix-agent) compiles on your machine, and the same build ships over the air.

## Quick start

Expand All @@ -36,12 +37,30 @@
-H "Authorization: Bearer $NODRIX_TOKEN"
```

## Flashing hardware from the browser

The Code page pairs an editor with a serial monitor and flashes over Web Serial, so a board goes from sketch to running without leaving the browser. Compiling is the one part a browser cannot do — the ESP32 sysroot alone is over 150 MB — so it runs on your own machine via the agent.

```bash
curl -fsSL -o nodrix-agent \
https://github.com/decoded-cipher/nodrix-agent/releases/latest/download/nodrix-agent-macos-arm64
chmod +x nodrix-agent
arduino-cli core install esp32:esp32

NODRIX_INSTANCE=https://<your-worker> NODRIX_TOKEN=<admin token> ./nodrix-agent
```

Other platforms are on the [agent releases](https://github.com/decoded-cipher/nodrix-agent/releases/latest). The agent dials out to your instance, so there is no port to open and no certificate to install; it never touches the serial port, which the browser owns. Flashing needs Chrome, Edge or Opera on desktop, or Chrome on Android — Safari and Firefox have no Web Serial.

Keep a build and it becomes a firmware version you can send to any device over the air.

## Architecture

- **Worker** ([worker/](worker/)) — a single Hono app. Durable Objects for Project, Dashboard, Scheduler, and the MCP agent; one Workflow for provisioning; D1 (metadata), R2 (telemetry history), KV (read cache + JWKS).
- **Web** ([web/](web/)) — Vue 3 + Tailwind + Reka UI admin panel and drag-and-drop dashboard builder, built and served as Worker static assets.
- **Shared** ([shared/](shared/)) — framework-agnostic Web Component widgets, the integration catalog, and automation blocks, consumed by both web and worker so there is a single source of truth.
- **Deploy** ([deploy/](deploy/)) — the small config carrier behind the one-click Deploy to Cloudflare.
- **Agent** ([nodrix-agent](https://github.com/decoded-cipher/nodrix-agent)) — an optional CLI on your own machine that runs `arduino-cli` for browser builds. Separate repo, separate release.

```
worker/ Cloudflare Worker — API, Durable Objects, Workflow
Expand Down
9 changes: 5 additions & 4 deletions deploy/wrangler.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# nodrix deploy carrier. The Deploy to Cloudflare button clones only this
# subdir; the build command pulls the real source from upstream over it. Don't
# edit by hand — manage the deployment from the Cloudflare dashboard. Keep the
# bindings in sync with the repo-root wrangler.toml.
# nodrix deploy carrier. The Deploy to Cloudflare button clones only this subdir;
# the build command pulls the real source from upstream over it and rebuilds the
# deployment's wrangler.toml from this file, keeping only its Worker name,
# account, routes, resource IDs and vars. Everything else here reaches every
# existing deployment on its next build — keep it in sync with the root config.
name = "nodrix"
main = "worker/src/index.ts"
compatibility_date = "2025-05-01"
Expand Down
24 changes: 18 additions & 6 deletions scripts/build-from-upstream.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
# source of truth — meaning code changes in upstream never reach them. With
# this script, every deploy:
#
# 1. Preserves the user's wrangler.toml (which has their resource IDs,
# filled by the Deploy button on day 1 and never changed since).
# 2. Replaces every other file with the upstream source's contents.
# 1. Replaces every file with the upstream source's contents.
# 2. Rebuilds wrangler.toml from upstream's carrier template, keeping only the
# deployment's identity (Worker name, account, routes, resource IDs, vars).
# Keeping the whole file instead froze the topology at day 1, so a binding
# or flag added upstream never reached anyone who had already deployed.
# 3. Runs upstream's build pipeline.
#
# Result: the user's clone is functionally a config carrier. Code = upstream.
Expand All @@ -31,6 +33,7 @@ UPSTREAM_REPO="${NODRIX_UPSTREAM_REPO:-decoded-cipher/nodrix}"
DEPLOY_CHANNEL="${NODRIX_DEPLOY_CHANNEL:-release}"
UPSTREAM_DIR="/tmp/nodrix-upstream"
WRANGLER_BACKUP="/tmp/nodrix-wrangler.toml"
WRANGLER_MERGED="/tmp/nodrix-wrangler.merged.toml"

if [ -z "${WORKERS_CI_COMMIT_SHA:-}" ]; then
echo "[build-from-upstream] not in Workers Builds CI — running local build chain"
Expand All @@ -43,7 +46,7 @@ fi

echo "[build-from-upstream] CI build — pulling upstream ${UPSTREAM_REPO} (${DEPLOY_CHANNEL} channel)"

# 1. Preserve user's wrangler.toml.
# 1. Save the deployment's wrangler.toml; step 4 merges it back.
if [ ! -f wrangler.toml ]; then
echo "[build-from-upstream] no wrangler.toml in cwd — refusing to proceed" >&2
exit 1
Expand Down Expand Up @@ -114,8 +117,17 @@ for dir in web worker scripts; do
done
done

# 4. Restore user's wrangler.toml in case upstream had its own (which it does).
cp "${WRANGLER_BACKUP}" wrangler.toml
# 4. Rebuild wrangler.toml. This script comes from master but the clone is the
# release tag, so a release predating the merge script falls back instead of
# failing.
if [ -f scripts/merge-wrangler.ts ] && [ -f ./deploy/wrangler.toml ]; then
echo "[build-from-upstream] merging deployment identity into upstream wrangler.toml"
bun scripts/merge-wrangler.ts "${WRANGLER_BACKUP}" ./deploy/wrangler.toml > "${WRANGLER_MERGED}"
mv "${WRANGLER_MERGED}" wrangler.toml
else
echo "[build-from-upstream] upstream has no merge script — keeping wrangler.toml as-is"
cp "${WRANGLER_BACKUP}" wrangler.toml
fi

# 4b. Drop the nested deploy/ that the overlay just brought in. The clone root
# IS the deploy carrier; upstream's own deploy/ dir is dead weight here and
Expand Down
116 changes: 116 additions & 0 deletions scripts/merge-wrangler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Getting this wrong on a live deployment points it at resources it doesn't own,
// or renames the Worker. Run with `bun test scripts/merge-wrangler.test.ts`.

import { test, expect } from 'bun:test';
import { mergeWrangler } from './merge-wrangler';

// Renamed, on a custom domain, tracking a fork, and predating three template changes.
const DEPLOYMENT = `name = "home-iot"
main = "worker/src/index.ts"
compatibility_date = "2025-05-01"
compatibility_flags = ["nodejs_compat"]
account_id = "acc_123"
routes = [
{ pattern = "iot.example.com", custom_domain = true }
]

[[d1_databases]]
binding = "DB"
database_name = "home-iot-db"
database_id = "aaaa-bbbb-cccc"

[[kv_namespaces]]
binding = "KV"
id = "kv_deadbeef"

[[r2_buckets]]
binding = "R2"
bucket_name = "home-iot-telemetry"

[vars]
NODRIX_UPSTREAM_REPO = "someone/nodrix-fork"
`;

const TEMPLATE = `name = "nodrix"
main = "worker/src/index.ts"
compatibility_date = "2026-01-15"
compatibility_flags = ["nodejs_compat"]

[build]
command = "curl -fsSL https://example.invalid/build.sh | bash"

[[d1_databases]]
binding = "DB"
database_name = "nodrix"
database_id = "PLACEHOLDER_FILLED_BY_DEPLOY_OR_WRANGLER"
migrations_dir = "worker/src/platform/db/migrations"

[[kv_namespaces]]
binding = "KV"
id = "PLACEHOLDER_FILLED_BY_DEPLOY_OR_WRANGLER"

[[r2_buckets]]
binding = "R2"
bucket_name = "nodrix-telemetry"

[[durable_objects.bindings]]
name = "PROJECT_DO"
class_name = "ProjectDO"

[[migrations]]
tag = "v2"
new_sqlite_classes = ["DeviceDO"]

[vars]
NODRIX_UPSTREAM_REPO = "decoded-cipher/nodrix"
NODRIX_FEATURE_FLAG = "on"

[triggers]
crons = ["0 0 * * *"]
`;

const merged = mergeWrangler(DEPLOYMENT, TEMPLATE);

test('keeps the deployment worker name', () => {
expect(merged).toContain('name = "home-iot"');
expect(merged).not.toContain('name = "nodrix"');
});

test('keeps resource ids the deployment owns', () => {
expect(merged).toContain('database_id = "aaaa-bbbb-cccc"');
expect(merged).toContain('database_name = "home-iot-db"');
expect(merged).toContain('id = "kv_deadbeef"');
expect(merged).toContain('bucket_name = "home-iot-telemetry"');
expect(merged).not.toContain('PLACEHOLDER');
});

test('keeps account and routes the template never declares', () => {
expect(merged).toContain('account_id = "acc_123"');
expect(merged).toContain('pattern = "iot.example.com"');
});

test('takes compatibility settings and build config from upstream', () => {
expect(merged).toContain('compatibility_date = "2026-01-15"');
expect(merged).toContain('https://example.invalid/build.sh');
});

test('takes new bindings, migrations and triggers from upstream', () => {
expect(merged).toContain('class_name = "ProjectDO"');
expect(merged).toContain('new_sqlite_classes = ["DeviceDO"]');
expect(merged).toContain('crons = ["0 0 * * *"]');
expect(merged).toContain('migrations_dir = "worker/src/platform/db/migrations"');
});

test('keeps an overridden var and adds one the deployment predates', () => {
expect(merged).toContain('NODRIX_UPSTREAM_REPO = "someone/nodrix-fork"');
expect(merged).toContain('NODRIX_FEATURE_FLAG = "on"');
});

test('is idempotent against its own output', () => {
expect(mergeWrangler(merged, TEMPLATE)).toBe(merged);
});

test('a fresh deployment carrying only placeholders is left as the template', () => {
const fresh = mergeWrangler(TEMPLATE, TEMPLATE);
expect(fresh).toBe(TEMPLATE);
});
171 changes: 171 additions & 0 deletions scripts/merge-wrangler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Rebuilds a deployment's wrangler.toml: bindings, flags and build config from
// upstream's carrier template, identity from the deployment's own file.
//
// Keeping the deployment's file verbatim froze its topology at whatever the
// Deploy button wrote on day one; taking upstream's verbatim would rename the
// Worker, creating a second one and orphaning the live one.

import { readFileSync } from 'node:fs';

const PRESERVED_TOP_KEYS = ['name', 'account_id', 'workers_dev', 'preview_urls', 'route', 'routes'];

const PRESERVED_RESOURCE_KEYS: Record<string, string[]> = {
d1_databases: ['database_id', 'database_name'],
kv_namespaces: ['id'],
r2_buckets: ['bucket_name'],
};

type Section = { header: string; lines: string[] };

function keyOf(line: string): string | null {
const m = /^\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*=/.exec(line);
return m ? m[1]! : null;
}

// TOML arrays and inline tables can span lines; those join into one entry.
function unclosed(text: string): boolean {
let depth = 0;
let quote = '';
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (quote) {
if (c === '\\') i++;
else if (c === quote) quote = '';
continue;
}
if (c === '"' || c === "'") quote = c;
else if (c === '#') break;
else if (c === '[' || c === '{') depth++;
else if (c === ']' || c === '}') depth--;
}
return depth > 0;
}

function parse(text: string): Section[] {
const sections: Section[] = [{ header: '', lines: [] }];
const raw = text.split('\n');
for (let i = 0; i < raw.length; i++) {
let line = raw[i]!;
if (line.trim().startsWith('[')) {
sections.push({ header: line.trim(), lines: [] });
continue;
}
if (keyOf(line)) {
while (unclosed(line) && i + 1 < raw.length) line += '\n' + raw[++i]!;
}
sections[sections.length - 1]!.lines.push(line);
}
return sections;
}

function valueOf(line: string): string {
const eq = line.indexOf('=');
return line.slice(eq + 1).trim().replace(/\s*#.*$/, '').replace(/^["']|["']$/g, '');
}

function lookup(section: Section, key: string): string | undefined {
for (const l of section.lines) if (keyOf(l) === key) return l;
return undefined;
}

function arrayName(header: string): string | null {
const m = /^\[\[([A-Za-z0-9_.-]+)\]\]$/.exec(header);
return m ? m[1]! : null;
}

export function mergeWrangler(sourceText: string, templateText: string): string {
const source = parse(sourceText);
const template = parse(templateText);

const sourceTop = source[0]!;
const sourceResources = new Map<string, Section>();
let sourceVars: Section | undefined;
for (const s of source) {
const name = arrayName(s.header);
if (name && name in PRESERVED_RESOURCE_KEYS) {
const binding = lookup(s, 'binding');
if (binding) sourceResources.set(`${name}:${valueOf(binding)}`, s);
} else if (s.header === '[vars]') {
sourceVars = s;
}
}

const out: string[] = [];
const usedTopKeys = new Set<string>();

for (const s of template) {
if (s.header) out.push(s.header);

const name = arrayName(s.header);
const resourceKeys = name ? PRESERVED_RESOURCE_KEYS[name] : undefined;
let resource: Section | undefined;
if (resourceKeys) {
const binding = lookup(s, 'binding');
if (binding) {
resource = sourceResources.get(`${name}:${valueOf(binding)}`);
if (!resource) {
console.error(
`[merge-wrangler] ${name} binding ${valueOf(binding)} is new upstream — this deployment has no id for it`
);
}
}
}

for (const line of s.lines) {
const key = keyOf(line);
if (!key) {
out.push(line);
continue;
}
if (!s.header && PRESERVED_TOP_KEYS.includes(key)) {
const own = lookup(sourceTop, key);
usedTopKeys.add(key);
out.push(own ?? line);
continue;
}
if (resource && resourceKeys!.includes(key)) {
out.push(lookup(resource, key) ?? line);
continue;
}
if (s.header === '[vars]' && sourceVars) {
out.push(lookup(sourceVars, key) ?? line);
continue;
}
out.push(line);
}

// Custom domains and account ids the template never declares.
if (!s.header) {
const extra = PRESERVED_TOP_KEYS.filter((k) => !usedTopKeys.has(k) && lookup(sourceTop, k));
if (extra.length) {
let at = out.length;
while (at > 0 && !keyOf(out[at - 1]!)) at--;
out.splice(at, 0, ...extra.map((k) => lookup(sourceTop, k)!));
}
}
if (s.header === '[vars]' && sourceVars) {
const declared = new Set(s.lines.map(keyOf).filter(Boolean));
for (const line of sourceVars.lines) {
const k = keyOf(line);
if (k && !declared.has(k)) out.push(line);
}
}
}

if (sourceVars && !template.some((s) => s.header === '[vars]')) {
out.push('', '[vars]', ...sourceVars.lines.filter((l) => keyOf(l)));
}

return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n*$/, '\n');
}

if (import.meta.main) {
const [, , sourcePath, templatePath] = process.argv;
if (!sourcePath || !templatePath) {
console.error('usage: merge-wrangler.ts <deployment.toml> <upstream-template.toml>');
process.exit(1);
}
process.stdout.write(
mergeWrangler(readFileSync(sourcePath, 'utf8'), readFileSync(templatePath, 'utf8'))
);
}
Loading
Loading