@getstrata/cli 2.1.0 → 2.2.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @getstrata/cli changelog
2
2
 
3
+ ## 2.2.1
4
+
5
+ F02 — complete queue durability. Queues have leased reservations, recovery and fenced acknowledgement, but still use Redis lists. Persisted retry schedules, execution deadlines, cancellation and the planned Streams migration remain unfinished. Retries currently wait inside the running worker.
6
+ F10 — production public-route tenancy. Separate anonymous access from development tenant headers, support a trusted-host resolver, reject unknown hosts, and accept forwarded hosts only through configured trusted proxies. The shop still lacks the approved-domain mapping.
7
+ F12 — initial administrator provisioning and migration guidance. Automatic demo seeding is fixed. Secure initial-admin provisioning and a consolidated upgrade/operations guide remain.
8
+ Distributed qualification. Cache generations/fill leases, scheduler ownership, OpenTelemetry, body limits and lifecycle controls now exist. Their implementation should no longer be described as entirely pending, but broader failure and capacity qualification is still required.
9
+
10
+ ## 2.2.0
11
+
12
+ Consistent model scopes.
13
+ Changed-field model writes.
14
+ Awaited provider startup.
15
+ Shared cache correctness.
16
+ Native HTTP controls and body limits.
17
+ Graceful lifecycle.
18
+ Validated async discovery.
19
+ Stronger model input types.
20
+ OpenTelemetry tracing.
21
+ Distributed scheduler ownership.
22
+ App-owned quota policy.
23
+ Bounded memory throttling.
24
+
3
25
  ## 2.1.0
4
26
 
5
27
  - refactor(starter): reuse shared config and base repositories - fix(database): await password casts before model writes - fix(database): preserve concrete model types in query results
@@ -48,6 +70,8 @@
48
70
 
49
71
  ## Unreleased
50
72
 
73
+ - Await queue/scheduler application startup and close resources after failed startup or completed work; preserve original failures when cleanup also fails.
74
+
51
75
  - 2.0: migrations no longer implicitly call an app seed export. Seeding requires --seed; fresh accepts the same explicit flag. Missing seed exports are rejected before applying migrations or resetting data.
52
76
 
53
77
  ## 1.1.9
package/README.md CHANGED
@@ -19,7 +19,7 @@ strata help
19
19
 
20
20
  `strata start` does not migrate when `APP_ENV=production`. Run `strata migrate` as an explicit deploy step.
21
21
 
22
- App commands from `src/cli/register.ts` merge with this list. Generated apps add `queue:work`, failed-job commands, `make:*`, `openapi:*`, and `schedule:run`.
22
+ App commands from `src/cli/register.ts` merge with this list. Generated apps add `queue:work`, failed-job commands, `make:*`, `openapi:*`, and `schedule:run`. User-auth starters also add explicit `auth:provision-admin`; its `initialAdmin` subpath reads bounded password input from a pipe and redacts provisioning errors. The app owns its transactional one-time claim and user schema. See [initial-admin provisioning](https://github.com/EyK-26/strata/blob/main/docs/INITIAL-ADMIN.md) before adoption.
23
23
 
24
24
  ## Running it
25
25
 
@@ -51,7 +51,7 @@ From the current working directory, `strata` loads `strata.config.ts` if present
51
51
  | `migrate` | `src/db/migrate.ts` |
52
52
  | `fresh` | `src/db/fresh.ts` |
53
53
 
54
- `src/cli/register.ts` can export `commands` or `registerCommands()` to add your own commands. Generated apps ship `queue:work`, which boots the app (`bootstrapApp` / `createApp`) rather than the monorepo provider stack, plus `make:*`, failed-job commands, `openapi:*`, and `schedule:run`.
54
+ `src/cli/register.ts` can export `commands` or `registerCommands()` to add your own commands. Generated apps ship `queue:work`, which boots the app (`bootstrapApp` / `createApp`) rather than the monorepo provider stack, plus `make:*`, failed-job commands, `openapi:*`, and `schedule:run`. User-auth starters also add explicit `auth:provision-admin`; its `initialAdmin` subpath reads bounded password input from a pipe and redacts provisioning errors. The app owns its transactional one-time claim and user schema. See [initial-admin provisioning](https://github.com/EyK-26/strata/blob/main/docs/INITIAL-ADMIN.md) before adoption.
55
55
 
56
56
  Helpers live on package subpaths: `@getstrata/cli/queueWorker`, `@getstrata/cli/queueFailed`, `@getstrata/cli/scaffold`, `@getstrata/cli/openapi`, and `@getstrata/cli/schedule`. `queue:work` takes `{ boot, close }` and does not call `assertProductionSecrets()` (that belongs in app boot). Scaffold commands resolve paths from the app working directory (`src/modules`, `src/db/migrations`, `src/jobs`, `views/` or `resources/views`). See [docs/BUILDING-APPS.md](https://github.com/EyK-26/strata/blob/main/docs/BUILDING-APPS.md#extending-the-cli).
57
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/cli",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "Strata CLI: framework commands for Bun apps (dev, start, migrate, run)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,6 +36,10 @@
36
36
  "./schedule": {
37
37
  "import": "./src/schedule.ts",
38
38
  "default": "./src/schedule.ts"
39
+ },
40
+ "./initialAdmin": {
41
+ "import": "./src/initialAdmin.ts",
42
+ "default": "./src/initialAdmin.ts"
39
43
  }
40
44
  },
41
45
  "files": [
@@ -46,8 +50,8 @@
46
50
  ],
47
51
  "scripts": {},
48
52
  "peerDependencies": {
49
- "@getstrata/bootstrap": "^2.1.0",
50
- "@getstrata/core": "^2.1.0"
53
+ "@getstrata/bootstrap": "^2.2.1",
54
+ "@getstrata/core": "^2.2.1"
51
55
  },
52
56
  "publishConfig": {
53
57
  "access": "public"
@@ -0,0 +1,99 @@
1
+ import { stdin } from "node:process";
2
+
3
+ type InitialAdminInput = {
4
+ email: string;
5
+ name: string;
6
+ password: string;
7
+ tenantId?: number;
8
+ };
9
+
10
+ type InitialAdminOptions = {
11
+ tenantRequired: boolean;
12
+ provision: (input: InitialAdminInput) => Promise<void>;
13
+ /** Trusted test/embedding input; never accept password arguments or environment defaults. */
14
+ input?: AsyncIterable<Uint8Array>;
15
+ };
16
+
17
+ async function runInitialAdminCommand(args: string[], options: InitialAdminOptions): Promise<void> {
18
+ const flags = new Map<string, string>();
19
+ let passwordStdin = false;
20
+ for (let index = 0; index < args.length; index++) {
21
+ const flag = args[index];
22
+ if (flag === "--password-stdin" && !passwordStdin) {
23
+ passwordStdin = true;
24
+ } else if (flag === "--email" || flag === "--name" || flag === "--tenant") {
25
+ const value = args[++index];
26
+ if (!value || value.startsWith("--") || flags.has(flag)) {
27
+ throw new Error("Initial-admin options must have one explicit value each.");
28
+ }
29
+ flags.set(flag, value);
30
+ } else {
31
+ throw new Error("Use --email, --name, --password-stdin and, for tenant apps, --tenant.");
32
+ }
33
+ }
34
+ const email = (flags.get("--email") ?? "").trim().toLowerCase();
35
+ const name = (flags.get("--name") ?? "").trim();
36
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254 || /[\p{C}]/u.test(email)) {
37
+ throw new Error("A valid initial-admin email is required.");
38
+ }
39
+ if (!name || name.length > 200 || /[\p{C}]/u.test(name)) {
40
+ throw new Error("A nonempty initial-admin name without control characters is required.");
41
+ }
42
+ const tenantRaw = flags.get("--tenant");
43
+ const tenantId = tenantRaw === undefined ? undefined : Number(tenantRaw);
44
+ if (options.tenantRequired) {
45
+ if (!tenantRaw || !/^[1-9]\d*$/.test(tenantRaw) || !Number.isSafeInteger(tenantId)) {
46
+ throw new Error("An existing positive safe-integer tenant ID is required.");
47
+ }
48
+ } else if (tenantRaw !== undefined) {
49
+ throw new Error("This application does not accept a tenant option.");
50
+ }
51
+ if (!passwordStdin || (!options.input && stdin.isTTY)) {
52
+ throw new Error(
53
+ "Provide --password-stdin through a pipe; interactive terminal input is refused.",
54
+ );
55
+ }
56
+ const chunks: Uint8Array[] = [];
57
+ let size = 0;
58
+ // Bcrypt accepts at most 72 UTF-8 bytes; allow a single terminal CRLF.
59
+ for await (const chunk of options.input ?? stdin) {
60
+ size += chunk.byteLength;
61
+ if (size > 74) throw new Error("Initial-admin password exceeds the supported byte limit.");
62
+ chunks.push(chunk);
63
+ }
64
+ const bytes = new Uint8Array(size);
65
+ let offset = 0;
66
+ for (const chunk of chunks) {
67
+ bytes.set(chunk, offset);
68
+ offset += chunk.byteLength;
69
+ }
70
+ let password: string;
71
+ try {
72
+ password = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true })
73
+ .decode(bytes)
74
+ .replace(/\r?\n$/, "");
75
+ } catch {
76
+ throw new Error("Initial-admin password must be valid UTF-8.");
77
+ }
78
+ if (
79
+ [...password].length < 16 ||
80
+ new TextEncoder().encode(password).byteLength > 72 ||
81
+ /[\p{C}]/u.test(password) ||
82
+ password === "StrataDemo!ChangeMe"
83
+ ) {
84
+ throw new Error(
85
+ "Use a unique password of at least 16 characters and at most 72 UTF-8 bytes, without control characters or demo defaults.",
86
+ );
87
+ }
88
+ try {
89
+ await options.provision({ email, name, password, tenantId });
90
+ } catch {
91
+ // Drivers/observers can include bound values in errors. Never forward credential-bearing causes.
92
+ throw new Error(
93
+ "Initial-admin provisioning failed. Check schema, tenant, existing admins/accounts and the one-time claim; no account is promoted or overwritten.",
94
+ );
95
+ }
96
+ }
97
+
98
+ export type { InitialAdminInput, InitialAdminOptions };
99
+ export { runInitialAdminCommand };
@@ -1,6 +1,10 @@
1
- import { createFailedJobService } from "@getstrata/core/queue/createAppQueue";
1
+ import {
2
+ createAppQueue,
3
+ createFailedJobService,
4
+ resolveQueueConfig,
5
+ } from "@getstrata/core/queue/createAppQueue";
2
6
  import { jobRegistry } from "@getstrata/core/queue/jobRegistry";
3
- import { runQueueJob } from "@getstrata/core/queue/jobRunner";
7
+ import { resolveApplicationQueue } from "@getstrata/core/runtime/applicationRegistry";
4
8
  import type { StrataCommand } from "./types.ts";
5
9
 
6
10
  type QueueFailedBoot = () => unknown | Promise<unknown>;
@@ -25,26 +29,30 @@ async function queueRetryCommand(id?: string, boot?: QueueFailedBoot): Promise<v
25
29
  throw new Error("queue:retry requires a failed job id.");
26
30
  }
27
31
 
32
+ const jobId = Number(id);
33
+ if (!Number.isSafeInteger(jobId) || jobId <= 0)
34
+ throw new Error("queue:retry requires a positive integer failed job id.");
28
35
  await boot?.();
29
36
  const failedJobs = createFailedJobService();
30
- const failedJob = await failedJobs.retry(Number.parseInt(id, 10));
31
-
32
- const job = jobRegistry.create(failedJob.job_name);
33
-
34
- if (!job) {
35
- throw new Error(`Unknown job "${failedJob.job_name}".`);
37
+ // App boot owns its configured queue; standalone CLI dispatch owns and closes its queue.
38
+ const queue = boot
39
+ ? resolveApplicationQueue()
40
+ : createAppQueue(resolveQueueConfig().driver, process.env.REDIS_URL, failedJobs);
41
+ try {
42
+ await failedJobs.retry(jobId, async (failedJob) => {
43
+ const job = jobRegistry.create(failedJob.job_name);
44
+ if (!job) throw new Error(`Unknown job "${failedJob.job_name}".`);
45
+ jobRegistry.track(failedJob.job_name, job);
46
+ if (failedJob.job_id !== null && failedJob.job_id !== undefined) {
47
+ if (!queue.replay)
48
+ throw new Error("Configured queue cannot preserve the failed-job identity");
49
+ await queue.replay(job, failedJob.payload, failedJob.job_id);
50
+ } else await queue.dispatch(job, failedJob.payload);
51
+ });
52
+ console.log(`Enqueued failed job #${jobId}.`);
53
+ } finally {
54
+ if (!boot) await queue.close?.();
36
55
  }
37
-
38
- await runQueueJob(
39
- {
40
- name: failedJob.job_name,
41
- payload: failedJob.payload,
42
- attempts: 0,
43
- },
44
- failedJobs,
45
- );
46
-
47
- console.log(`Retried failed job #${id}.`);
48
56
  }
49
57
 
50
58
  async function queueFlushFailedCommand(boot?: QueueFailedBoot): Promise<void> {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  installGracefulShutdownSignals,
3
- registerShutdownHandler,
3
+ LifecycleCoordinator,
4
4
  } from "@getstrata/core/lifecycle/gracefulShutdown";
5
5
  import { createFailedJobService, createQueueWorker } from "@getstrata/core/queue/createAppQueue";
6
6
 
@@ -10,35 +10,91 @@ type QueueWorkerClose = () => unknown | Promise<unknown>;
10
10
  async function runQueueWorkerCommand(options: {
11
11
  boot: QueueWorkerBoot;
12
12
  close?: QueueWorkerClose;
13
+ flush?: QueueWorkerClose;
14
+ drain?: QueueWorkerClose;
15
+ lifecycle?: LifecycleCoordinator;
13
16
  failedJobs?: ReturnType<typeof createFailedJobService>;
14
17
  }): Promise<void> {
15
18
  const redisUrl = process.env.REDIS_URL;
16
-
17
- if (!redisUrl) {
18
- throw new Error("queue:work requires REDIS_URL to be set.");
19
+ if (!redisUrl) throw new Error("queue:work requires REDIS_URL to be set.");
20
+ const lifecycle = options.lifecycle ?? new LifecycleCoordinator();
21
+ if (lifecycle.isShuttingDown) throw new Error("Cannot start a worker during shutdown.");
22
+ let worker: ReturnType<typeof createQueueWorker> | undefined;
23
+ let running: Promise<void> | undefined;
24
+ let booting: Promise<unknown> | undefined;
25
+ let stopping = false;
26
+ const name = `queue:${crypto.randomUUID()}`;
27
+ lifecycle.register(
28
+ `${name}:stop`,
29
+ () => {
30
+ stopping = true;
31
+ worker?.requestStop();
32
+ },
33
+ "stop",
34
+ );
35
+ lifecycle.register(
36
+ `${name}:drain`,
37
+ async () => {
38
+ // Completion, including failed startup/run, means no admitted worker remains.
39
+ await booting?.catch(() => {});
40
+ await running?.catch(() => {});
41
+ await options.drain?.();
42
+ },
43
+ "drain",
44
+ );
45
+ lifecycle.register(
46
+ `${name}:flush`,
47
+ async () => {
48
+ await options.flush?.();
49
+ },
50
+ "flush",
51
+ );
52
+ lifecycle.register(
53
+ `${name}:close`,
54
+ async () => {
55
+ try {
56
+ await worker?.close?.();
57
+ } finally {
58
+ await options.close?.();
59
+ }
60
+ },
61
+ "close",
62
+ );
63
+ lifecycle.register(
64
+ `${name}:force`,
65
+ () => {
66
+ worker?.requestStop();
67
+ worker?.close?.();
68
+ },
69
+ "force",
70
+ );
71
+ const uninstall = installGracefulShutdownSignals(undefined, lifecycle);
72
+ let failure: unknown;
73
+ let failed = false;
74
+ try {
75
+ booting = (async () => {
76
+ await options.boot();
77
+ })();
78
+ await booting;
79
+ if (!stopping && !lifecycle.isShuttingDown) {
80
+ worker = createQueueWorker(redisUrl, options.failedJobs ?? createFailedJobService());
81
+ console.log("[queue:work] Listening for jobs on Redis...");
82
+ running = worker.run();
83
+ await running;
84
+ console.log("[queue:work] Worker stopped.");
85
+ }
86
+ } catch (error) {
87
+ failed = true;
88
+ failure = error;
19
89
  }
20
-
21
- await options.boot();
22
-
23
- const failedJobs = options.failedJobs ?? createFailedJobService();
24
-
25
- console.log("[queue:work] Listening for jobs on Redis...");
26
- const worker = createQueueWorker(redisUrl, failedJobs);
27
-
28
- registerShutdownHandler("queue-worker", async () => {
29
- worker.requestStop();
30
- });
31
-
32
- if (options.close) {
33
- registerShutdownHandler("database", async () => {
34
- await options.close?.();
35
- });
36
- }
37
-
38
- installGracefulShutdownSignals();
39
-
40
- await worker.run();
41
- console.log("[queue:work] Worker stopped.");
90
+ const result = await lifecycle.shutdown("WORKER_FINISHED");
91
+ uninstall();
92
+ if (!result.successful)
93
+ throw new AggregateError(
94
+ [...(failed ? [failure] : []), ...result.errors.map((entry) => entry.error)],
95
+ "Queue startup/runtime or shutdown failed.",
96
+ );
97
+ if (failed) throw failure;
42
98
  }
43
99
 
44
100
  export type { QueueWorkerBoot, QueueWorkerClose };
package/src/schedule.ts CHANGED
@@ -1,25 +1,87 @@
1
+ import {
2
+ installGracefulShutdownSignals,
3
+ LifecycleCoordinator,
4
+ } from "@getstrata/core/lifecycle/gracefulShutdown";
1
5
  import { appSchedule, runDueScheduledTasks } from "@getstrata/core/scheduler/schedule";
2
6
 
3
7
  type LoadAppSchedule = () => void | Promise<void>;
8
+ type CloseScheduledApp = () => void | Promise<void>;
4
9
 
5
- function createScheduleRunCommand(loadSchedule: LoadAppSchedule) {
10
+ function createScheduleRunCommand(
11
+ loadSchedule: LoadAppSchedule,
12
+ close?: CloseScheduledApp,
13
+ options: {
14
+ lifecycle?: LifecycleCoordinator;
15
+ flush?: CloseScheduledApp;
16
+ drain?: CloseScheduledApp;
17
+ } = {},
18
+ ) {
6
19
  return async function scheduleRunCommand(): Promise<void> {
7
- await loadSchedule();
8
-
9
- const due = appSchedule.dueTasks();
10
-
11
- if (due.length === 0) {
12
- console.log("No scheduled tasks due.");
13
- return;
20
+ const lifecycle = options.lifecycle ?? new LifecycleCoordinator();
21
+ if (lifecycle.isShuttingDown) throw new Error("Cannot start scheduling during shutdown.");
22
+ const admission = new AbortController();
23
+ let stopping = false;
24
+ let execution: Promise<void> | undefined;
25
+ const name = `scheduler:${crypto.randomUUID()}`;
26
+ lifecycle.register(
27
+ `${name}:stop`,
28
+ () => {
29
+ stopping = true;
30
+ admission.abort();
31
+ },
32
+ "stop",
33
+ );
34
+ lifecycle.register(
35
+ `${name}:drain`,
36
+ async () => {
37
+ await execution?.catch(() => {});
38
+ await options.drain?.();
39
+ },
40
+ "drain",
41
+ );
42
+ lifecycle.register(
43
+ `${name}:flush`,
44
+ async () => {
45
+ await options.flush?.();
46
+ },
47
+ "flush",
48
+ );
49
+ lifecycle.register(
50
+ `${name}:close`,
51
+ async () => {
52
+ await close?.();
53
+ },
54
+ "close",
55
+ );
56
+ const uninstall = installGracefulShutdownSignals(undefined, lifecycle);
57
+ let failed = false;
58
+ let failure: unknown;
59
+ try {
60
+ execution = (async () => {
61
+ await loadSchedule();
62
+ if (stopping) return;
63
+ const due = appSchedule.dueTasks();
64
+ if (due.length === 0) console.log("No scheduled tasks due.");
65
+ else {
66
+ for (const task of due) console.log(`Running scheduled task: ${task.name}`);
67
+ await runDueScheduledTasks(appSchedule, new Date(), { signal: admission.signal });
68
+ }
69
+ })();
70
+ await execution;
71
+ } catch (error) {
72
+ failed = true;
73
+ failure = error;
14
74
  }
15
-
16
- for (const task of due) {
17
- console.log(`Running scheduled task: ${task.name}`);
18
- }
19
-
20
- await runDueScheduledTasks(appSchedule);
75
+ const result = await lifecycle.shutdown("SCHEDULE_FINISHED");
76
+ uninstall();
77
+ if (!result.successful)
78
+ throw new AggregateError(
79
+ [...(failed ? [failure] : []), ...result.errors.map((entry) => entry.error)],
80
+ "Scheduler startup/runtime or shutdown failed.",
81
+ );
82
+ if (failed) throw failure;
21
83
  };
22
84
  }
23
85
 
24
- export type { LoadAppSchedule };
86
+ export type { CloseScheduledApp, LoadAppSchedule };
25
87
  export { createScheduleRunCommand };