@nanobpm/urban 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,12 +4,12 @@
4
4
  // urban check load + validate the manifest, report issues
5
5
  // urban gen derive generated artifacts (migrations, worker I/O)
6
6
  // urban run materialize + serve the app (deploy, data, workers, surfaces, triggers)
7
- // urban dev like run (hot-reload is not yet implemented; documented below)
7
+ // urban dev like run, plus watch sources and hot-reload on change
8
8
  // urban deploy deploy models only, then exit
9
9
  //
10
10
  // Global flags: --root <dir> (default "."), --manifest <file> (default nano.app.json),
11
11
  // --port <n>, -h/--help, -v/--version.
12
- import { collectManifestIssues, loadManifest, runFromEnv, selectHost, } from "./runtime/index.js";
12
+ import { collectManifestIssues, installSignalHandlers, loadManifest, runDev, runFromEnv, selectHost, } from "./runtime/index.js";
13
13
  import { scaffold, slugify } from "create-urban-app";
14
14
  import { createNodeGenIO, runGen } from "./toolkit/index.js";
15
15
  import { pathToFileURL } from "node:url";
@@ -27,7 +27,7 @@ function readVersion() {
27
27
  }
28
28
  const VERSION = readVersion();
29
29
  function parse(argv) {
30
- const f = { root: ".", manifest: "nano.app.json", check: false, help: false, version: false, _: [] };
30
+ const f = { root: ".", manifest: "nano.app.json", check: false, deno: false, help: false, version: false, _: [] };
31
31
  const need = (i, flag) => {
32
32
  const v = argv[i];
33
33
  if (v === undefined || v.startsWith("-"))
@@ -42,6 +42,10 @@ function parse(argv) {
42
42
  f.version = true;
43
43
  else if (a === "--check")
44
44
  f.check = true;
45
+ else if (a === "--deno")
46
+ f.deno = true;
47
+ else if (a === "--")
48
+ continue; // tolerate a bare "--" some runners inject (not an option terminator here)
45
49
  else if (a === "--root")
46
50
  f.root = need(++i, a);
47
51
  else if (a === "--manifest")
@@ -64,11 +68,11 @@ function parse(argv) {
64
68
  const USAGE = `urban — build and run Urban apps (nano.app.json)
65
69
 
66
70
  Usage:
67
- urban new <name> [--root <path>] scaffold a new Urban app
71
+ urban new <name> [--root <path>] [--deno] scaffold a new Urban app
68
72
  urban check validate the manifest
69
73
  urban gen [--check] derive artifacts (migrations, worker-io)
70
74
  urban run materialize + serve the app
71
- urban dev run (hot-reload not yet implemented)
75
+ urban dev run + watch sources, hot-reload on change
72
76
  urban deploy deploy models only, then exit
73
77
 
74
78
  Global flags:
@@ -124,6 +128,11 @@ async function cmdRun(f, mount) {
124
128
  console.log(`▲ ${info.name} — surfaces on :${info.httpPort ?? "n/a"} (Ctrl-C to stop)`);
125
129
  return -1; // keep the process alive; signal handlers stop it
126
130
  }
131
+ async function cmdDev(f) {
132
+ const dev = await runDev({ root: f.root, manifestPath: f.manifest, port: f.port });
133
+ installSignalHandlers(() => dev.stop());
134
+ return -1; // keep the process alive; signal handlers stop the dev server
135
+ }
127
136
  async function cmdDeploy(f) {
128
137
  const app = await runFromEnv({
129
138
  root: f.root,
@@ -138,13 +147,16 @@ async function cmdDeploy(f) {
138
147
  async function cmdNew(f) {
139
148
  const name = f._[1];
140
149
  if (!name) {
141
- console.error("usage: urban new <name> [--root <path>]");
150
+ console.error("usage: urban new <name> [--root <path>] [--deno]");
142
151
  return 1;
143
152
  }
144
153
  const dir = f.root !== "." ? f.root : `./${slugify(name)}`;
145
- const res = await scaffold({ name, dir });
154
+ const res = await scaffold({ name, dir, deno: f.deno });
146
155
  console.log(`✔ scaffolded "${res.id}" in ${res.dir} (${res.files.length} files)`);
147
- console.log(` cd ${res.dir} && (npm install && npm start) || deno task start`);
156
+ console.log(` cd ${res.dir}`);
157
+ console.log(` npm install && npm start`);
158
+ if (f.deno)
159
+ console.log(` # or on Deno: deno task start`);
148
160
  return 0;
149
161
  }
150
162
  export async function main(argv) {
@@ -176,8 +188,7 @@ export async function main(argv) {
176
188
  case "run":
177
189
  return cmdRun(f);
178
190
  case "dev":
179
- console.log("urban dev: hot-reload is not yet implemented; running once.");
180
- return cmdRun(f);
191
+ return cmdDev(f);
181
192
  case "deploy":
182
193
  return cmdDeploy(f);
183
194
  default:
@@ -2,5 +2,7 @@ import type { HostContext } from "../core/host.ts";
2
2
  export interface DenoHostOptions {
3
3
  cwd?: string;
4
4
  log?: HostContext["log"];
5
+ /** See NodeHostOptions.importNonce — appended as `?v=<nonce>` to dynamic imports. */
6
+ importNonce?: string;
5
7
  }
6
8
  export declare function createDenoHost(opts?: DenoHostOptions): HostContext;
@@ -51,10 +51,42 @@ export function createDenoHost(opts = {}) {
51
51
  const db = new DatabaseSync(abs(path));
52
52
  return wrapSqlite(db);
53
53
  },
54
- importModule: (p) => import(__rewriteRelativeImportExtension(pathToFileURL(abs(p)).href)),
54
+ importModule: (p) => {
55
+ const href = pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
56
+ return import(__rewriteRelativeImportExtension(href));
57
+ },
55
58
  async serveHttp(port, handler) {
56
59
  return startDenoServer(port, handler);
57
60
  },
61
+ watch(onChange) {
62
+ const w = Deno.watchFs(cwd, { recursive: true });
63
+ let closed = false;
64
+ (async () => {
65
+ try {
66
+ for await (const ev of w) {
67
+ for (const p of ev.paths)
68
+ onChange(p);
69
+ }
70
+ }
71
+ catch (err) {
72
+ // Deno.watchFs throws when close() is called mid-await — expected on shutdown.
73
+ // Any other failure (permissions, path removed, …) silently disables hot reload,
74
+ // so surface it instead of swallowing it.
75
+ if (!closed)
76
+ log("warn", "file watch error — hot reload may be disabled", {
77
+ error: String(err),
78
+ });
79
+ }
80
+ })();
81
+ return {
82
+ close: () => {
83
+ if (closed)
84
+ return;
85
+ closed = true;
86
+ w.close();
87
+ },
88
+ };
89
+ },
58
90
  now: () => Date.now(),
59
91
  log,
60
92
  };
@@ -4,6 +4,8 @@ export declare function isDeno(): boolean;
4
4
  export interface SelectHostOptions {
5
5
  cwd?: string;
6
6
  log?: HostContext["log"];
7
+ /** Dev-only import-cache-busting nonce; forwarded to the chosen adapter. */
8
+ importNonce?: string;
7
9
  }
8
10
  /** Select and construct the host for the current runtime. */
9
11
  export declare function selectHost(opts?: SelectHostOptions): HostContext;
@@ -3,5 +3,11 @@ export interface NodeHostOptions {
3
3
  /** Base directory relative paths resolve against. Default process.cwd(). */
4
4
  cwd?: string;
5
5
  log?: HostContext["log"];
6
+ /**
7
+ * When set, appended as a `?v=<nonce>` query to dynamic import URLs so a changed
8
+ * handler/worker module is re-evaluated instead of served from the ESM cache. The
9
+ * dev server bumps this on every reload; production leaves it unset.
10
+ */
11
+ importNonce?: string;
6
12
  }
7
13
  export declare function createNodeHost(opts?: NodeHostOptions): HostContext;
@@ -9,6 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  return path;
10
10
  };
11
11
  import { readFile, readdir, stat } from "node:fs/promises";
12
+ import { watch as fsWatch } from "node:fs";
12
13
  import { createServer } from "node:http";
13
14
  import { isAbsolute, resolve } from "node:path";
14
15
  import { pathToFileURL } from "node:url";
@@ -47,10 +48,36 @@ export function createNodeHost(opts = {}) {
47
48
  const db = new DatabaseSync(abs(path));
48
49
  return wrapNodeSqlite(db);
49
50
  },
50
- importModule: (p) => import(__rewriteRelativeImportExtension(pathToFileURL(abs(p)).href)),
51
+ importModule: (p) => {
52
+ const href = pathToFileURL(abs(p)).href + (opts.importNonce ? `?v=${opts.importNonce}` : "");
53
+ return import(__rewriteRelativeImportExtension(href));
54
+ },
51
55
  async serveHttp(port, handler) {
52
56
  return await startNodeServer(port, handler);
53
57
  },
58
+ watch(onChange) {
59
+ const onFsEvent = (_event, filename) => {
60
+ if (filename)
61
+ onChange(String(filename));
62
+ };
63
+ // Recursive watch is supported on macOS, Windows, and — since Node 19.1.0 — Linux.
64
+ // This package requires Node >=22.6, so the recursive path is available on all three;
65
+ // the try/catch only trips on an unusual platform, where we degrade honestly to a
66
+ // non-recursive root watch (and warn) rather than silently miss nested changes.
67
+ let w;
68
+ try {
69
+ w = fsWatch(cwd, { recursive: true }, onFsEvent);
70
+ }
71
+ catch (err) {
72
+ log("warn", "recursive file watch unavailable — nested changes may be missed", {
73
+ error: String(err),
74
+ });
75
+ w = fsWatch(cwd, onFsEvent);
76
+ }
77
+ // A watcher error (e.g. the dir is removed) must not crash the dev server.
78
+ w.on("error", (err) => log("warn", "file watch error", { error: String(err) }));
79
+ return { close: () => w.close() };
80
+ },
54
81
  now: () => Date.now(),
55
82
  log,
56
83
  };
@@ -21,6 +21,10 @@ export interface HttpServer {
21
21
  readonly port: number;
22
22
  stop(): Promise<void>;
23
23
  }
24
+ /** A handle to an active filesystem watch; call close() to stop watching. */
25
+ export interface WatchHandle {
26
+ close(): void;
27
+ }
24
28
  /** A tiny synchronous SQLite handle — the subset the runtime needs. */
25
29
  export interface SqliteDb {
26
30
  /** Execute one or more statements with no result (DDL, PRAGMA, migrations). */
@@ -58,6 +62,13 @@ export interface HostContext {
58
62
  importModule(path: string): Promise<Record<string, unknown>>;
59
63
  /** Start an HTTP server. Routing is done by the caller inside `handler`. */
60
64
  serveHttp(port: number, handler: HttpHandler): Promise<HttpServer>;
65
+ /**
66
+ * Recursively watch the app root for file changes, invoking `onChange` with the
67
+ * changed path (app-root-relative when the runtime reports it that way). Optional:
68
+ * hosts that cannot watch omit it, and callers fall back to run-once. Returns a
69
+ * handle whose close() stops watching.
70
+ */
71
+ watch?(onChange: (path: string) => void): WatchHandle;
61
72
  /** Current wall-clock time in ms since epoch (seam for tests). */
62
73
  now(): number;
63
74
  /** Structured log sink. */
@@ -0,0 +1,36 @@
1
+ import type { HostContext } from "./core/host.ts";
2
+ import type { UrbanApp } from "./core/runtime.ts";
3
+ /** True when a changed path should trigger a dev reload. Pure, so it is unit-tested.
4
+ * `manifestFile` (the app's configured manifest, default nano.app.json) always triggers,
5
+ * so `urban dev --manifest other.json` reloads on edits to that file too. */
6
+ export declare function shouldReload(rawPath: string, manifestFile?: string): boolean;
7
+ export interface DevOptions {
8
+ root?: string;
9
+ manifestPath?: string;
10
+ port?: number;
11
+ /** Quiet-period after the last change before reloading, in ms. Default 150. */
12
+ debounceMs?: number;
13
+ /** Log sink (default console.log). */
14
+ log?: (msg: string) => void;
15
+ }
16
+ /** Injectable seams so the loop is testable without a live engine or filesystem. */
17
+ export interface DevDeps {
18
+ makeHost(nonce: string): HostContext;
19
+ startApp(host: HostContext, o: {
20
+ manifestPath: string;
21
+ port?: number;
22
+ }): Promise<UrbanApp>;
23
+ regenerate(root: string, manifestFile: string): Promise<{
24
+ count: number;
25
+ }>;
26
+ now(): number;
27
+ }
28
+ export interface DevServer {
29
+ stop(): Promise<void>;
30
+ }
31
+ /**
32
+ * Start the dev server: gen once, start the app, then watch + hot-reload. Returns a handle
33
+ * whose stop() tears the watcher and the running app down. Never rejects on a reload error —
34
+ * a bad edit logs and leaves the previous app running.
35
+ */
36
+ export declare function runDev(opts?: DevOptions, deps?: Partial<DevDeps>): Promise<DevServer>;
@@ -0,0 +1,173 @@
1
+ // `urban dev` — a hot-reload dev server. It runs the derivers once, starts the app, then
2
+ // watches the app root and, on a relevant source change, regenerates artifacts and restarts
3
+ // the app in-process. A per-reload import nonce busts the module cache so changed worker/
4
+ // action handlers are re-evaluated. main.ts is NOT re-run in dev (the CLI drives the runtime
5
+ // directly), so main.ts edits don't need a reload.
6
+ //
7
+ // The reload strategy is a full in-process restart: regenerate + build a fresh host, then
8
+ // stop the old app and start the new one. Regenerating first means a bad edit (invalid
9
+ // manifest/model) fails before the running app is torn down, so it keeps serving. It is the
10
+ // same start path as a fresh `urban run`, so every change kind — manifest, BPMN/DMN, forms,
11
+ // migrations/types, worker code — is picked up. A finer-grained surface remount + browser
12
+ // live-reload can layer on later behind this same seam.
13
+ import { runFromEnv } from "./run.js";
14
+ import { selectHost } from "./adapters/detect.js";
15
+ import { createNodeGenIO, runGen } from "../toolkit/index.js";
16
+ const SOURCE_EXT = [".bpmn", ".dmn", ".form", ".ts", ".js", ".mjs", ".sql"];
17
+ // Directories whose churn must never trigger a reload: generated output (gen writes here —
18
+ // watching it would loop forever), dependencies, VCS, and build output.
19
+ const IGNORE_SEGMENTS = ["nano-generated", "node_modules", ".git", "dist"];
20
+ /** True when a changed path should trigger a dev reload. Pure, so it is unit-tested.
21
+ * `manifestFile` (the app's configured manifest, default nano.app.json) always triggers,
22
+ * so `urban dev --manifest other.json` reloads on edits to that file too. */
23
+ export function shouldReload(rawPath, manifestFile = "nano.app.json") {
24
+ const norm = (s) => s.replace(/\\/g, "/").replace(/^\.\//, "");
25
+ const p = norm(rawPath);
26
+ if (p === "")
27
+ return false;
28
+ for (const seg of IGNORE_SEGMENTS) {
29
+ if (p === seg || p.startsWith(`${seg}/`) || p.includes(`/${seg}/`))
30
+ return false;
31
+ }
32
+ // Ignore SQLite database files and their WAL/SHM/journal sidecars — they change on every
33
+ // write and would otherwise wedge the server in a reload loop.
34
+ if (/\.(db|sqlite|sqlite3)(-wal|-shm|-journal)?$/i.test(p))
35
+ return false;
36
+ const mf = norm(manifestFile);
37
+ const base = p.slice(p.lastIndexOf("/") + 1);
38
+ if (p === mf || base === mf.slice(mf.lastIndexOf("/") + 1))
39
+ return true;
40
+ return SOURCE_EXT.some((e) => p.toLowerCase().endsWith(e));
41
+ }
42
+ function defaultDeps(root) {
43
+ return {
44
+ makeHost: (nonce) => selectHost({ cwd: root, importNonce: nonce }),
45
+ startApp: (host, o) =>
46
+ // The host is anchored at `root`; runFromEnv keeps its own root at "." accordingly.
47
+ runFromEnv({ host, manifestPath: o.manifestPath, port: o.port, handleSignals: false }),
48
+ regenerate: async (r, mf) => {
49
+ const res = await runGen({ root: r, io: createNodeGenIO(), manifestFile: mf });
50
+ return { count: res.artifacts.length };
51
+ },
52
+ now: () => Date.now(),
53
+ };
54
+ }
55
+ /**
56
+ * Start the dev server: gen once, start the app, then watch + hot-reload. Returns a handle
57
+ * whose stop() tears the watcher and the running app down. Never rejects on a reload error —
58
+ * a bad edit logs and leaves the previous app running.
59
+ */
60
+ export async function runDev(opts = {}, deps) {
61
+ const root = opts.root ?? ".";
62
+ const manifestFile = opts.manifestPath ?? "nano.app.json";
63
+ const debounceMs = opts.debounceMs ?? 150;
64
+ const log = opts.log ?? ((m) => console.log(m));
65
+ const d = { ...defaultDeps(root), ...deps };
66
+ // Monotonic reload counter, mixed into the import nonce so it is unique per reload even when
67
+ // two reloads land in the same millisecond (d.now() can repeat).
68
+ let reloadSeq = 0;
69
+ const startCycle = async () => {
70
+ const host = await prepareHost();
71
+ const app = await d.startApp(host, { manifestPath: manifestFile, port: opts.port });
72
+ return { host, app };
73
+ };
74
+ // Derive artifacts and build a fresh host (with a new import nonce). This is pure/FS work
75
+ // that does NOT touch the running app, so it can run *before* a reload tears the old app
76
+ // down — most bad edits (invalid manifest/model) fail here, leaving the app serving.
77
+ const prepareHost = async () => {
78
+ const nonce = `${d.now()}-${reloadSeq++}`;
79
+ const { count } = await d.regenerate(root, manifestFile);
80
+ if (count > 0)
81
+ log(` derived ${count} artifact(s)`);
82
+ return d.makeHost(nonce);
83
+ };
84
+ let { host, app } = await startCycle();
85
+ let reloading = false;
86
+ let queued = false;
87
+ let stopped = false;
88
+ let appRunning = true;
89
+ let inFlight;
90
+ let timer;
91
+ const reload = async () => {
92
+ try {
93
+ log("↻ change detected — reloading…");
94
+ // Regenerate + rebuild the host BEFORE stopping the running app, so a bad edit
95
+ // throws here and the current app keeps serving untouched.
96
+ const nextHost = await prepareHost();
97
+ if (stopped)
98
+ return; // shutdown began mid-prepare: leave the running app to stop()
99
+ await app.stop();
100
+ appRunning = false;
101
+ // Re-check after the async stop: if shutdown began while we were tearing the old
102
+ // app down, don't start a replacement just to have stop() immediately kill it.
103
+ if (stopped)
104
+ return;
105
+ app = await d.startApp(nextHost, { manifestPath: manifestFile, port: opts.port });
106
+ appRunning = true;
107
+ host = nextHost;
108
+ log("✔ reloaded");
109
+ }
110
+ catch (err) {
111
+ // Keep the dev server alive on a bad edit; the next save will retry.
112
+ log(`✖ reload failed: ${String(err?.message ?? err)}`);
113
+ }
114
+ finally {
115
+ reloading = false;
116
+ if (queued && !stopped) {
117
+ queued = false;
118
+ schedule();
119
+ }
120
+ }
121
+ };
122
+ // Owns the single-flight guard and `inFlight` bookkeeping: only an *actually running*
123
+ // reload is tracked in `inFlight`, so stop() always awaits the real in-flight reload
124
+ // (never an already-resolved no-op promise) before final teardown.
125
+ const trigger = () => {
126
+ if (stopped)
127
+ return;
128
+ if (reloading) {
129
+ queued = true; // the active reload's finally re-schedules the next cycle
130
+ return;
131
+ }
132
+ reloading = true;
133
+ inFlight = reload();
134
+ };
135
+ const schedule = () => {
136
+ if (stopped)
137
+ return;
138
+ if (timer)
139
+ clearTimeout(timer);
140
+ timer = setTimeout(() => {
141
+ timer = undefined;
142
+ trigger();
143
+ }, debounceMs);
144
+ };
145
+ let watcher;
146
+ if (host.watch) {
147
+ watcher = host.watch((path) => {
148
+ if (shouldReload(path, manifestFile))
149
+ schedule();
150
+ });
151
+ log("▲ dev server ready — watching for changes (Ctrl-C to stop)");
152
+ }
153
+ else {
154
+ log("▲ dev server ready — file watching is not supported on this host; running once " +
155
+ "(no hot reload, Ctrl-C to stop)");
156
+ }
157
+ return {
158
+ async stop() {
159
+ // Order matters: flag first so a queued/in-flight reload won't start a new app, cancel
160
+ // any pending debounce, stop watching, then let any in-flight reload settle before the
161
+ // final teardown so we stop exactly the app that is (or ends up) current.
162
+ stopped = true;
163
+ if (timer) {
164
+ clearTimeout(timer);
165
+ timer = undefined;
166
+ }
167
+ watcher?.close();
168
+ await inFlight?.catch(() => { });
169
+ if (appRunning)
170
+ await app.stop();
171
+ },
172
+ };
173
+ }
@@ -4,7 +4,7 @@ export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType,
4
4
  export type { ActionDecl, AppManifest, DataSource, DomainType, LlmBinding, SurfaceDecl, TriggerDecl, TypeField, WorkerDecl, } from "./core/manifest.ts";
5
5
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
6
6
  export type { ValidationIssue } from "./core/validate.ts";
7
- export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WorkerSubscription, } from "./core/host.ts";
7
+ export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WatchHandle, WorkerSubscription, } from "./core/host.ts";
8
8
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
9
9
  export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
10
10
  export type { ProvisionedSource } from "./core/modules/datasource.ts";
@@ -25,5 +25,7 @@ export { isDeno, selectHost } from "./adapters/detect.ts";
25
25
  export { createNanoSdkEngineClient, SdkEngineClient } from "./engine/nanosdk.ts";
26
26
  export type { NanoSdkClient, NanoSdkEngineOptions, NanoSdkJobWorker, NanoSdkJobWorkerConfig, } from "./engine/nanosdk.ts";
27
27
  export type { EngineSdkClient } from "./engine/sdk.ts";
28
- export { runFromEnv } from "./run.ts";
28
+ export { installSignalHandlers, runFromEnv } from "./run.ts";
29
29
  export type { RunOptions } from "./run.ts";
30
+ export { runDev, shouldReload } from "./devserver.ts";
31
+ export type { DevDeps, DevOptions, DevServer } from "./devserver.ts";
@@ -18,4 +18,5 @@ export { createNodeHost } from "./adapters/node.js";
18
18
  export { createDenoHost } from "./adapters/deno.js";
19
19
  export { isDeno, selectHost } from "./adapters/detect.js";
20
20
  export { createNanoSdkEngineClient, SdkEngineClient } from "./engine/nanosdk.js";
21
- export { runFromEnv } from "./run.js";
21
+ export { installSignalHandlers, runFromEnv } from "./run.js";
22
+ export { runDev, shouldReload } from "./devserver.js";
@@ -15,3 +15,4 @@ export interface RunOptions extends Partial<CreateUrbanAppOptions> {
15
15
  handleSignals?: boolean;
16
16
  }
17
17
  export declare function runFromEnv(opts?: RunOptions): Promise<UrbanApp>;
18
+ export declare function installSignalHandlers(stop: () => Promise<void>): void;
@@ -32,7 +32,7 @@ export async function runFromEnv(opts = {}) {
32
32
  installSignalHandlers(() => app.stop());
33
33
  return app;
34
34
  }
35
- function installSignalHandlers(stop) {
35
+ export function installSignalHandlers(stop) {
36
36
  let stopping = false;
37
37
  const onSignal = () => {
38
38
  if (stopping)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -61,7 +61,7 @@
61
61
  "@nanobpm/nano-sdk": "^1.2.0",
62
62
  "@nanobpm/workflow": "^0.7.0",
63
63
  "bpmn-auto-layout": "^2.0.0-alpha.2",
64
- "create-urban-app": "^0.2.0"
64
+ "create-urban-app": "^0.4.0"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@types/node": "^22.20.1",
package/src/cli.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // urban check load + validate the manifest, report issues
5
5
  // urban gen derive generated artifacts (migrations, worker I/O)
6
6
  // urban run materialize + serve the app (deploy, data, workers, surfaces, triggers)
7
- // urban dev like run (hot-reload is not yet implemented; documented below)
7
+ // urban dev like run, plus watch sources and hot-reload on change
8
8
  // urban deploy deploy models only, then exit
9
9
  //
10
10
  // Global flags: --root <dir> (default "."), --manifest <file> (default nano.app.json),
@@ -12,7 +12,9 @@
12
12
 
13
13
  import {
14
14
  collectManifestIssues,
15
+ installSignalHandlers,
15
16
  loadManifest,
17
+ runDev,
16
18
  runFromEnv,
17
19
  selectHost,
18
20
  } from "./runtime/index.ts";
@@ -40,13 +42,14 @@ interface Flags {
40
42
  manifest: string;
41
43
  port?: number;
42
44
  check: boolean;
45
+ deno: boolean;
43
46
  help: boolean;
44
47
  version: boolean;
45
48
  _: string[];
46
49
  }
47
50
 
48
51
  function parse(argv: string[]): Flags {
49
- const f: Flags = { root: ".", manifest: "nano.app.json", check: false, help: false, version: false, _: [] };
52
+ const f: Flags = { root: ".", manifest: "nano.app.json", check: false, deno: false, help: false, version: false, _: [] };
50
53
  const need = (i: number, flag: string): string => {
51
54
  const v = argv[i];
52
55
  if (v === undefined || v.startsWith("-")) throw new Error(`flag ${flag} requires a value`);
@@ -57,6 +60,8 @@ function parse(argv: string[]): Flags {
57
60
  if (a === "-h" || a === "--help") f.help = true;
58
61
  else if (a === "-v" || a === "--version") f.version = true;
59
62
  else if (a === "--check") f.check = true;
63
+ else if (a === "--deno") f.deno = true;
64
+ else if (a === "--") continue; // tolerate a bare "--" some runners inject (not an option terminator here)
60
65
  else if (a === "--root") f.root = need(++i, a);
61
66
  else if (a === "--manifest") f.manifest = need(++i, a);
62
67
  else if (a === "--port") {
@@ -75,11 +80,11 @@ function parse(argv: string[]): Flags {
75
80
  const USAGE = `urban — build and run Urban apps (nano.app.json)
76
81
 
77
82
  Usage:
78
- urban new <name> [--root <path>] scaffold a new Urban app
83
+ urban new <name> [--root <path>] [--deno] scaffold a new Urban app
79
84
  urban check validate the manifest
80
85
  urban gen [--check] derive artifacts (migrations, worker-io)
81
86
  urban run materialize + serve the app
82
- urban dev run (hot-reload not yet implemented)
87
+ urban dev run + watch sources, hot-reload on change
83
88
  urban deploy deploy models only, then exit
84
89
 
85
90
  Global flags:
@@ -136,6 +141,12 @@ async function cmdRun(f: Flags, mount?: Record<string, boolean>): Promise<number
136
141
  return -1; // keep the process alive; signal handlers stop it
137
142
  }
138
143
 
144
+ async function cmdDev(f: Flags): Promise<number> {
145
+ const dev = await runDev({ root: f.root, manifestPath: f.manifest, port: f.port });
146
+ installSignalHandlers(() => dev.stop());
147
+ return -1; // keep the process alive; signal handlers stop the dev server
148
+ }
149
+
139
150
  async function cmdDeploy(f: Flags): Promise<number> {
140
151
  const app = await runFromEnv({
141
152
  root: f.root,
@@ -151,13 +162,15 @@ async function cmdDeploy(f: Flags): Promise<number> {
151
162
  async function cmdNew(f: Flags): Promise<number> {
152
163
  const name = f._[1];
153
164
  if (!name) {
154
- console.error("usage: urban new <name> [--root <path>]");
165
+ console.error("usage: urban new <name> [--root <path>] [--deno]");
155
166
  return 1;
156
167
  }
157
168
  const dir = f.root !== "." ? f.root : `./${slugify(name)}`;
158
- const res = await scaffold({ name, dir });
169
+ const res = await scaffold({ name, dir, deno: f.deno });
159
170
  console.log(`✔ scaffolded "${res.id}" in ${res.dir} (${res.files.length} files)`);
160
- console.log(` cd ${res.dir} && (npm install && npm start) || deno task start`);
171
+ console.log(` cd ${res.dir}`);
172
+ console.log(` npm install && npm start`);
173
+ if (f.deno) console.log(` # or on Deno: deno task start`);
161
174
  return 0;
162
175
  }
163
176
 
@@ -189,8 +202,7 @@ export async function main(argv: string[]): Promise<number> {
189
202
  case "run":
190
203
  return cmdRun(f);
191
204
  case "dev":
192
- console.log("urban dev: hot-reload is not yet implemented; running once.");
193
- return cmdRun(f);
205
+ return cmdDev(f);
194
206
  case "deploy":
195
207
  return cmdDeploy(f);
196
208
  default: