Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 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
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
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'))
);
}
1 change: 1 addition & 0 deletions shared/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type BlockFieldType =
| 'number'
| 'boolean'
| 'variable'
| 'device'
| 'integration'
| 'time'
| 'weekdays';
Expand Down
6 changes: 6 additions & 0 deletions shared/blocks/triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export const TRIGGER_CATALOG = [
ports: { out: ['out'] },
fields: [
{ key: 'variable', label: 'Variable', type: 'variable', required: true },
{
key: 'device',
label: 'Device',
type: 'device',
hint: 'Leave as any device to fire whichever board reports it.',
},
{
key: 'operator',
label: 'Condition',
Expand Down
Loading