@happyvertical/smrt-template-sveltekit 0.43.9 → 0.44.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.
Files changed (41) hide show
  1. package/AGENTS.md +31 -6
  2. package/README.md +26 -10
  3. package/index.js +53 -2
  4. package/package.json +19 -5
  5. package/template/.dockerignore +12 -0
  6. package/template/.env.cloud.example +9 -0
  7. package/template/.env.example +15 -3
  8. package/template/.env.self-hosted.example +11 -0
  9. package/template/AGENTS.md +32 -0
  10. package/template/Dockerfile +23 -0
  11. package/template/INSTALL_PROMPT.md +11 -0
  12. package/template/README.md +74 -32
  13. package/template/compose.yaml +56 -0
  14. package/template/gitignore.template +45 -0
  15. package/template/package.json +30 -15
  16. package/template/scripts/smrt-app.mjs +884 -0
  17. package/template/scripts/smrt-operation-lock.mjs +102 -0
  18. package/template/scripts/smrt-portability-assets.mjs +707 -0
  19. package/template/scripts/smrt-portability.mjs +470 -0
  20. package/template/scripts/smrt-prepare-migration.mjs +93 -0
  21. package/template/scripts/smrt-process.mjs +82 -0
  22. package/template/scripts/smrt-provider-readiness.mjs +33 -0
  23. package/template/scripts/smrt-runtime-identity.mjs +238 -0
  24. package/template/scripts/smrt-vite.mjs +45 -0
  25. package/template/scripts/smrt-web.mjs +10 -0
  26. package/template/scripts/smrt-worker.mjs +86 -0
  27. package/template/scripts/smrt-writer-lease.mjs +199 -0
  28. package/template/smrt.config.ts +34 -2
  29. package/template/src/hooks.server.ts +4 -1
  30. package/template/src/lib/RuntimeDiagnosticsWebMcp.svelte +11 -0
  31. package/template/src/lib/runtime-diagnostics-webmcp.ts +108 -0
  32. package/template/src/lib/server/application-runtime.ts +262 -0
  33. package/template/src/lib/server/runtime-diagnostics.ts +114 -0
  34. package/template/src/lib/server/smrt.ts +4 -4
  35. package/template/src/routes/+layout.svelte +2 -0
  36. package/template/src/routes/api/_runtime/diagnostics/+server.ts +5 -0
  37. package/template/src/routes/api/_runtime/health/+server.ts +30 -0
  38. package/template/src/routes/setup/+page.server.ts +96 -0
  39. package/template/src/routes/setup/+page.svelte +31 -0
  40. package/template/svelte.config.js +1 -1
  41. package/template/tsconfig.json +1 -0
@@ -0,0 +1,884 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { randomBytes } from 'node:crypto';
5
+ import {
6
+ accessSync,
7
+ constants,
8
+ cpSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ renameSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ } from 'node:fs';
16
+ import { platform } from 'node:os';
17
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
18
+ import { pathToFileURL } from 'node:url';
19
+
20
+ import {
21
+ initializeLocalApplicationRuntime,
22
+ resolveLocalRuntimePaths,
23
+ validateLocalDatabaseStorage,
24
+ } from '@happyvertical/smrt-app-runtime';
25
+ import {
26
+ loadConfig,
27
+ resolveConfiguredApplicationRuntime,
28
+ } from '@happyvertical/smrt-config';
29
+ import {
30
+ readOwnedProcess,
31
+ sendTerminationSignal,
32
+ writeProcessRecord,
33
+ } from './smrt-process.mjs';
34
+ import {
35
+ assertExternalArtifactPath,
36
+ prepareApplicationStateRoot,
37
+ resolveApplicationId,
38
+ resolveApplicationStateRoot,
39
+ runtimeConfigurationFingerprint,
40
+ } from './smrt-runtime-identity.mjs';
41
+ import { withOperationLock } from './smrt-operation-lock.mjs';
42
+ import { createProviderReadinessProbe } from './smrt-provider-readiness.mjs';
43
+ import { acquireWriterLease } from './smrt-writer-lease.mjs';
44
+
45
+ const sourceRoot = process.cwd();
46
+ try {
47
+ process.loadEnvFile(join(sourceRoot, '.env'));
48
+ } catch (error) {
49
+ if (
50
+ !(
51
+ error &&
52
+ typeof error === 'object' &&
53
+ 'code' in error &&
54
+ error.code === 'ENOENT'
55
+ )
56
+ ) {
57
+ throw error;
58
+ }
59
+ }
60
+ const packageJson = JSON.parse(
61
+ readFileSync(join(sourceRoot, 'package.json'), 'utf8'),
62
+ );
63
+ const appId = resolveApplicationId({
64
+ sourceRoot,
65
+ packageName: packageJson.name,
66
+ explicitId: process.env.SMRT_APP_ID,
67
+ });
68
+ const command = process.argv[2] || 'doctor';
69
+ const rawCommandArgs = process.argv.slice(3);
70
+ const commandArgs =
71
+ rawCommandArgs[0] === '--' ? rawCommandArgs.slice(1) : rawCommandArgs;
72
+
73
+ function preparedStateRoot() {
74
+ return prepareApplicationStateRoot({
75
+ appId,
76
+ dataDirectory: process.env.SMRT_DATA_DIR,
77
+ sourceRoot,
78
+ });
79
+ }
80
+
81
+ function ensurePrivateDirectory(path) {
82
+ mkdirSync(path, { recursive: true, mode: 0o700 });
83
+ }
84
+
85
+ function nearestExistingAncestor(path) {
86
+ let candidate = path;
87
+ while (!existsSync(candidate)) {
88
+ const parent = dirname(candidate);
89
+ if (parent === candidate) return candidate;
90
+ candidate = parent;
91
+ }
92
+ return candidate;
93
+ }
94
+
95
+ function pidPath() {
96
+ return join(preparedStateRoot(), 'app.pid');
97
+ }
98
+
99
+ function onboardingPath() {
100
+ return join(preparedStateRoot(), 'onboarding.json');
101
+ }
102
+
103
+ function onboardingLaunchPath() {
104
+ return join(preparedStateRoot(), 'onboarding-launch.html');
105
+ }
106
+
107
+ function removeOnboardingHandoff() {
108
+ rmSync(onboardingPath(), { force: true });
109
+ rmSync(onboardingLaunchPath(), { force: true });
110
+ }
111
+
112
+ function writePrivateAtomic(destination, contents) {
113
+ const temporary = `${destination}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
114
+ try {
115
+ writeFileSync(temporary, contents, { flag: 'wx', mode: 0o600 });
116
+ renameSync(temporary, destination);
117
+ } finally {
118
+ rmSync(temporary, { force: true });
119
+ }
120
+ }
121
+
122
+ function saveOnboardingLaunch(url) {
123
+ const escapedUrl = url.replaceAll('&', '&').replaceAll('"', '"');
124
+ writePrivateAtomic(
125
+ onboardingLaunchPath(),
126
+ `<!doctype html><meta http-equiv="refresh" content="0;url=${escapedUrl}">\n`,
127
+ );
128
+ }
129
+
130
+ function saveOnboardingUrl(url) {
131
+ ensurePrivateDirectory(preparedStateRoot());
132
+ saveOnboardingLaunch(url);
133
+ writePrivateAtomic(
134
+ onboardingPath(),
135
+ `${JSON.stringify({ schemaVersion: 1, url })}\n`,
136
+ );
137
+ }
138
+
139
+ function readOnboardingUrl() {
140
+ try {
141
+ const value = JSON.parse(readFileSync(onboardingPath(), 'utf8'));
142
+ if (value?.schemaVersion !== 1 || typeof value.url !== 'string') {
143
+ throw new Error('Invalid onboarding handoff.');
144
+ }
145
+ const url = new URL(value.url);
146
+ if (
147
+ url.protocol !== 'http:' ||
148
+ (url.hostname !== '127.0.0.1' && url.hostname !== 'localhost') ||
149
+ url.pathname !== '/setup'
150
+ ) {
151
+ throw new Error('Invalid onboarding handoff.');
152
+ }
153
+ return url.toString();
154
+ } catch (error) {
155
+ if (error?.code === 'ENOENT') {
156
+ removeOnboardingHandoff();
157
+ return null;
158
+ }
159
+ if (
160
+ error instanceof SyntaxError ||
161
+ (error instanceof Error &&
162
+ (error.message === 'Invalid onboarding handoff.' ||
163
+ error.code === 'ERR_INVALID_URL'))
164
+ ) {
165
+ removeOnboardingHandoff();
166
+ return null;
167
+ }
168
+ throw error;
169
+ }
170
+ }
171
+
172
+ function readProcess() {
173
+ return readOwnedProcess(pidPath());
174
+ }
175
+
176
+ function readPid() {
177
+ return readProcess()?.pid || null;
178
+ }
179
+
180
+ function run(binary, args, options = {}) {
181
+ const result = spawnSync(binary, args, {
182
+ cwd: sourceRoot,
183
+ env: options.env || process.env,
184
+ encoding: 'utf8',
185
+ shell: options.shell || false,
186
+ stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
187
+ });
188
+ if (!options.allowFailure && (result.error || result.status !== 0)) {
189
+ const error = new Error(
190
+ `${options.label || binary} failed with exit code ${result.status ?? 1}.`,
191
+ { cause: result.error },
192
+ );
193
+ error.exitCode = result.status ?? 1;
194
+ throw error;
195
+ }
196
+ return result;
197
+ }
198
+
199
+ function runPackageManager(args, options = {}) {
200
+ const npmExecPath = process.env.npm_execpath;
201
+ const pnpmExecPath =
202
+ npmExecPath && basename(npmExecPath).toLowerCase().startsWith('pnpm')
203
+ ? npmExecPath
204
+ : null;
205
+ return pnpmExecPath
206
+ ? run(process.execPath, [pnpmExecPath, ...args], options)
207
+ : run(platform() === 'win32' ? 'pnpm.cmd' : 'pnpm', args, {
208
+ ...options,
209
+ shell: platform() === 'win32',
210
+ });
211
+ }
212
+
213
+ async function resolveRuntime() {
214
+ await loadConfig({ cache: false });
215
+ return resolveConfiguredApplicationRuntime();
216
+ }
217
+
218
+ async function assertLocalOperation(operation) {
219
+ const runtime = await resolveRuntime();
220
+ if (runtime.profile !== 'local') {
221
+ throw new Error(
222
+ `${operation} is local-profile only; run the production Node build or container for deployed profiles.`,
223
+ );
224
+ }
225
+ return runtime;
226
+ }
227
+
228
+ function runtimeEnvironment(runtime) {
229
+ const env = {
230
+ ...process.env,
231
+ SMRT_APP_ID: appId,
232
+ SMRT_RUNTIME_PROFILE: runtime.profile,
233
+ HOST:
234
+ runtime.profile === 'local'
235
+ ? '127.0.0.1'
236
+ : process.env.HOST || '0.0.0.0',
237
+ PORT: process.env.PORT || '5173',
238
+ };
239
+ if (runtime.profile === 'local') {
240
+ const paths = resolveLocalRuntimePaths({
241
+ appId,
242
+ dataDirectory: process.env.SMRT_DATA_DIR,
243
+ sourceRoot,
244
+ });
245
+ env.DATABASE_TYPE = 'sqlite';
246
+ env.DATABASE_URL = paths.database;
247
+ env.SMRT_ASSETS_DIR = paths.assets;
248
+ return { env, paths, assetRoot: paths.assets };
249
+ }
250
+ if (!process.env.DATABASE_URL) {
251
+ throw new Error(
252
+ `${runtime.profile} requires DATABASE_URL; copy the matching env example and configure providers.`,
253
+ );
254
+ }
255
+ env.DATABASE_TYPE = 'postgres';
256
+ let assetRoot = null;
257
+ if (runtime.providers.assets.provider === 'local-files') {
258
+ if (!process.env.SMRT_ASSETS_DIR || !isAbsolute(process.env.SMRT_ASSETS_DIR)) {
259
+ throw new Error(
260
+ 'Filesystem-backed deployed profiles require an absolute SMRT_ASSETS_DIR outside the source tree.',
261
+ );
262
+ }
263
+ assetRoot = assertExternalArtifactPath({
264
+ sourceRoot,
265
+ path: process.env.SMRT_ASSETS_DIR,
266
+ label: 'Asset storage root',
267
+ });
268
+ env.SMRT_ASSETS_DIR = assetRoot;
269
+ }
270
+ return { env, paths: null, assetRoot };
271
+ }
272
+
273
+ async function initializeLocal(runtime, env, options = {}) {
274
+ if (runtime.profile !== 'local') return null;
275
+ const initialized = await initializeLocalApplicationRuntime({
276
+ appId,
277
+ dataDirectory: process.env.SMRT_DATA_DIR,
278
+ sourceRoot,
279
+ bindHost: env.HOST || '127.0.0.1',
280
+ providers: {
281
+ database: runtime.providers.database,
282
+ authentication: runtime.providers.authentication,
283
+ tenancy: runtime.providers.tenancy,
284
+ assets: runtime.providers.assets,
285
+ secrets: runtime.providers.secrets,
286
+ jobs: runtime.providers.jobs,
287
+ network: runtime.providers.network,
288
+ },
289
+ prepareDatabase: options.prepareDatabase
290
+ ? async () => {
291
+ runPackageManager(['exec', 'smrt', 'db:migrate'], {
292
+ env,
293
+ label: 'pnpm exec smrt db:migrate',
294
+ });
295
+ }
296
+ : undefined,
297
+ backgroundJobs: process.env.SMRT_BACKGROUND_JOBS === 'true',
298
+ });
299
+ return initialized;
300
+ }
301
+
302
+ async function setup(operationLock) {
303
+ const runtime = await resolveRuntime();
304
+ const operatorLease =
305
+ runtime.profile === 'local'
306
+ ? acquireWriterLease(preparedStateRoot(), {
307
+ operationInstance: operationLock?.instance,
308
+ })
309
+ : null;
310
+ try {
311
+ const { env } = runtimeEnvironment(runtime);
312
+
313
+ runPackageManager(['build'], { env, label: 'pnpm build' });
314
+
315
+ // The app-runtime owns and locks the local data root while its explicit,
316
+ // idempotent schema hook runs. This keeps first install and concurrent
317
+ // setup attempts on the same secure path.
318
+ let initialized = null;
319
+ if (runtime.profile === 'local') {
320
+ initialized = await initializeLocal(runtime, env, {
321
+ prepareDatabase: true,
322
+ });
323
+ } else {
324
+ runPackageManager(['exec', 'smrt', 'db:migrate'], {
325
+ env,
326
+ label: 'pnpm exec smrt db:migrate',
327
+ });
328
+ }
329
+ const port = env.PORT || '5173';
330
+ const onboardingUrl = initialized?.bootstrap
331
+ ? `http://127.0.0.1:${port}/setup?token=${encodeURIComponent(initialized.bootstrap.token)}`
332
+ : null;
333
+ const diagnostics = await initialized?.runtime.diagnostics();
334
+ if (onboardingUrl) saveOnboardingUrl(onboardingUrl);
335
+ if (diagnostics?.bootstrap.status === 'claimed') {
336
+ removeOnboardingHandoff();
337
+ }
338
+ await initialized?.runtime.db.close?.();
339
+
340
+ const retainedOnboardingUrl = onboardingUrl || readOnboardingUrl();
341
+ const onboardingAvailable =
342
+ runtime.profile === 'local' && retainedOnboardingUrl !== null;
343
+ const report = {
344
+ schemaVersion: 1,
345
+ status: 'ready',
346
+ profile: runtime.profile,
347
+ onboardingAvailable,
348
+ onboardingRecovery: onboardingAvailable ? 'pnpm app:open' : null,
349
+ secretValuesIncluded: false,
350
+ };
351
+ console.log(JSON.stringify(report, null, 2));
352
+ return { ...report, onboardingUrl: retainedOnboardingUrl };
353
+ } finally {
354
+ operatorLease?.release();
355
+ }
356
+ }
357
+
358
+ async function recoverOnboarding(operationLock) {
359
+ const runtime = await resolveRuntime();
360
+ const { env } = runtimeEnvironment(runtime);
361
+ if (runtime.profile !== 'local') {
362
+ throw new Error('Owner onboarding recovery is local-only.');
363
+ }
364
+ const operatorLease = acquireWriterLease(preparedStateRoot(), {
365
+ operationInstance: operationLock?.instance,
366
+ });
367
+ try {
368
+ const initialized = await initializeLocal(runtime, env);
369
+ if (!initialized) throw new Error('Local runtime initialization failed.');
370
+ const invitation = await initialized.runtime.rotateBootstrapInvitation();
371
+ const url = `http://127.0.0.1:${env.PORT || '5173'}/setup?token=${encodeURIComponent(invitation.token)}`;
372
+ saveOnboardingUrl(url);
373
+ await initialized.runtime.db.close?.();
374
+ console.log(
375
+ JSON.stringify({
376
+ schemaVersion: 1,
377
+ status: 'ready',
378
+ onboardingAvailable: true,
379
+ recovery: 'Run pnpm app:start, then pnpm app:open.',
380
+ secretValuesIncluded: false,
381
+ }),
382
+ );
383
+ } finally {
384
+ operatorLease.release();
385
+ }
386
+ }
387
+
388
+ async function waitForReady(url, pid, instance, configuration) {
389
+ const healthUrl = new URL('/api/_runtime/health', url);
390
+ for (let attempt = 0; attempt < 40; attempt += 1) {
391
+ try {
392
+ const response = await fetch(healthUrl, { redirect: 'manual' });
393
+ if (response.status === 200) {
394
+ const health = await response.json();
395
+ if (
396
+ health?.status === 'ready' &&
397
+ health.application === appId &&
398
+ health.instance === instance &&
399
+ health.configuration === configuration
400
+ ) {
401
+ process.kill(pid, 0);
402
+ return;
403
+ }
404
+ }
405
+ process.kill(pid, 0);
406
+ } catch {
407
+ try {
408
+ process.kill(pid, 0);
409
+ } catch {
410
+ throw new Error(
411
+ 'The application process exited before becoming ready.',
412
+ );
413
+ }
414
+ }
415
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
416
+ }
417
+ throw new Error(`The application did not become ready at ${url}.`);
418
+ }
419
+
420
+ async function start(operationLock) {
421
+ const runtime = await assertLocalOperation('app:start');
422
+ const { env } = runtimeEnvironment(runtime);
423
+ const url = `http://127.0.0.1:${env.PORT || '5173'}/`;
424
+ const configuration = runtimeConfigurationFingerprint(runtime, env);
425
+ const existing = readProcess();
426
+ if (existing) {
427
+ await waitForReady(url, existing.pid, existing.instance, configuration);
428
+ console.log(
429
+ JSON.stringify({ schemaVersion: 1, status: 'running', pid: existing.pid }),
430
+ );
431
+ return existing.pid;
432
+ }
433
+ ensurePrivateDirectory(preparedStateRoot());
434
+ const instance = randomBytes(16).toString('hex');
435
+ const child = spawn(
436
+ process.execPath,
437
+ ['scripts/smrt-web.mjs', `--smrt-instance=${instance}`],
438
+ {
439
+ cwd: sourceRoot,
440
+ env: {
441
+ ...env,
442
+ HOST:
443
+ runtime.profile === 'local' ? '127.0.0.1' : env.HOST || '0.0.0.0',
444
+ PORT: env.PORT || '5173',
445
+ SMRT_PROCESS_INSTANCE: instance,
446
+ SMRT_OPERATION_INSTANCE: operationLock?.instance,
447
+ },
448
+ detached: true,
449
+ stdio: 'ignore',
450
+ },
451
+ );
452
+ child.unref();
453
+ writeProcessRecord(pidPath(), { pid: child.pid, instance });
454
+ try {
455
+ await waitForReady(url, child.pid, instance, configuration);
456
+ } catch (error) {
457
+ try {
458
+ process.kill(child.pid, 'SIGTERM');
459
+ } catch {
460
+ // The child already exited; stale process state is removed below.
461
+ }
462
+ rmSync(pidPath(), { force: true });
463
+ throw error;
464
+ }
465
+ console.log(
466
+ JSON.stringify({ schemaVersion: 1, status: 'started', pid: child.pid }),
467
+ );
468
+ return child.pid;
469
+ }
470
+
471
+ async function stop() {
472
+ const pid = readPid();
473
+ if (!pid) {
474
+ rmSync(pidPath(), { force: true });
475
+ console.log(JSON.stringify({ schemaVersion: 1, status: 'stopped' }));
476
+ return;
477
+ }
478
+ if (!sendTerminationSignal(pid)) {
479
+ // The process exited after its identity was verified but before SIGTERM.
480
+ rmSync(pidPath(), { force: true });
481
+ console.log(JSON.stringify({ schemaVersion: 1, status: 'stopped', pid }));
482
+ return;
483
+ }
484
+ for (let attempt = 0; attempt < 40; attempt += 1) {
485
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
486
+ try {
487
+ process.kill(pid, 0);
488
+ } catch {
489
+ break;
490
+ }
491
+ if (attempt === 39) {
492
+ throw new Error(`Application process ${pid} did not stop cleanly.`);
493
+ }
494
+ }
495
+ rmSync(pidPath(), { force: true });
496
+ console.log(JSON.stringify({ schemaVersion: 1, status: 'stopped', pid }));
497
+ }
498
+
499
+ function openBrowser(url) {
500
+ if (process.env.SMRT_OPEN_STUB) {
501
+ writeFileSync(resolve(process.env.SMRT_OPEN_STUB), `${url}\n`);
502
+ return;
503
+ }
504
+ const [binary, args] =
505
+ platform() === 'darwin'
506
+ ? ['open', [url]]
507
+ : platform() === 'win32'
508
+ ? ['cmd', ['/c', 'start', '', url]]
509
+ : ['xdg-open', [url]];
510
+ run(binary, args);
511
+ }
512
+
513
+ async function open() {
514
+ const runtime = await resolveRuntime();
515
+ const host =
516
+ runtime.profile === 'local'
517
+ ? '127.0.0.1'
518
+ : process.env.HOST || '127.0.0.1';
519
+ const url = `http://${host}:${process.env.PORT || '5173'}/`;
520
+ const onboardingUrl =
521
+ runtime.profile === 'local' ? readOnboardingUrl() : null;
522
+ if (onboardingUrl) saveOnboardingLaunch(onboardingUrl);
523
+ const destination = onboardingUrl
524
+ ? pathToFileURL(onboardingLaunchPath()).toString()
525
+ : url;
526
+ openBrowser(destination);
527
+ console.log(JSON.stringify({ schemaVersion: 1, status: 'opened', url }));
528
+ }
529
+
530
+ async function doctor() {
531
+ const findings = [];
532
+ let runtime = null;
533
+ let paths = null;
534
+ let statePath = null;
535
+ let canInspectMigrations = false;
536
+ try {
537
+ runtime = await resolveRuntime();
538
+ } catch {
539
+ findings.push({
540
+ code: 'invalid-runtime-profile',
541
+ severity: 'error',
542
+ message: 'The canonical runtime profile is invalid.',
543
+ recovery: 'Select local, self-hosted, or cloud in smrt.config.ts.',
544
+ });
545
+ }
546
+
547
+ if (Number(process.versions.node.split('.')[0]) < 24) {
548
+ findings.push({
549
+ code: 'unsupported-node',
550
+ severity: 'error',
551
+ message: 'Node.js 24 or newer is required.',
552
+ recovery: 'Install the Node.js version declared in package.json engines.',
553
+ });
554
+ }
555
+
556
+ if (runtime) {
557
+ if (runtime.profile !== 'local') {
558
+ for (const [component, provider] of [
559
+ ['authentication', runtime.providers.authentication.provider],
560
+ ['assets', runtime.providers.assets.provider],
561
+ ['secrets', runtime.providers.secrets.provider],
562
+ ]) {
563
+ try {
564
+ await createProviderReadinessProbe(component, {
565
+ profile: runtime.profile,
566
+ provider,
567
+ })();
568
+ } catch {
569
+ findings.push({
570
+ code: 'provider-not-configured',
571
+ component,
572
+ severity: 'error',
573
+ message: `The ${component} provider is not ready.`,
574
+ recovery: `Configure and verify the installed ${component} provider readiness module.`,
575
+ });
576
+ }
577
+ }
578
+ }
579
+ try {
580
+ ({ paths } = runtimeEnvironment(runtime));
581
+ statePath = resolveApplicationStateRoot({
582
+ appId,
583
+ dataDirectory: process.env.SMRT_DATA_DIR,
584
+ sourceRoot,
585
+ });
586
+ accessSync(nearestExistingAncestor(statePath), constants.W_OK);
587
+ if (runtime.profile === 'local') {
588
+ paths = await validateLocalDatabaseStorage({
589
+ appId,
590
+ dataDirectory: process.env.SMRT_DATA_DIR,
591
+ sourceRoot,
592
+ });
593
+ }
594
+ canInspectMigrations = true;
595
+ const host =
596
+ process.env.HOST ||
597
+ (runtime.profile === 'local' ? '127.0.0.1' : '0.0.0.0');
598
+ if (
599
+ runtime.profile === 'local' &&
600
+ host !== '127.0.0.1' &&
601
+ host !== '::1'
602
+ ) {
603
+ findings.push({
604
+ code: 'unsafe-local-bind',
605
+ severity: 'error',
606
+ message: 'Local owner bootstrap may only bind to a loopback address.',
607
+ recovery: 'Unset HOST or set HOST=127.0.0.1.',
608
+ });
609
+ }
610
+ if (paths?.root) {
611
+ const writableParent = nearestExistingAncestor(paths.root);
612
+ accessSync(writableParent, constants.W_OK);
613
+ }
614
+ } catch (error) {
615
+ findings.push({
616
+ code: 'runtime-path-unavailable',
617
+ severity: 'error',
618
+ message: 'A required runtime path or provider configuration is unavailable.',
619
+ recovery:
620
+ error instanceof Error
621
+ ? error.message
622
+ : 'Configure a writable runtime data path.',
623
+ });
624
+ }
625
+
626
+ if (canInspectMigrations) try {
627
+ const status = runPackageManager(
628
+ ['exec', 'smrt', 'db:status', '--json'],
629
+ {
630
+ env: runtimeEnvironment(runtime).env,
631
+ capture: true,
632
+ allowFailure: true,
633
+ label: 'pnpm exec smrt db:status --json',
634
+ },
635
+ );
636
+ if (status.status !== 0) {
637
+ findings.push({
638
+ code: 'migration-status-failed',
639
+ severity: 'error',
640
+ message: 'Database migration status could not be verified.',
641
+ recovery: 'Run pnpm app:setup and inspect the private migration logs.',
642
+ });
643
+ } else {
644
+ const migrationStatus = JSON.parse(status.stdout || '{}');
645
+ const migrationRequired =
646
+ (migrationStatus.drift?.length || 0) > 0 ||
647
+ (migrationStatus.migrations?.failed?.actionRequired || 0) > 0 ||
648
+ migrationStatus.schemaContract?.ok === false ||
649
+ migrationStatus.preconditions?.some((item) => item.status === 'error');
650
+ if (migrationRequired) {
651
+ findings.push({
652
+ code: 'migration-required',
653
+ severity: 'error',
654
+ message: 'Database migrations are pending or failed.',
655
+ recovery: 'Run pnpm app:setup, then rerun pnpm app:doctor.',
656
+ });
657
+ }
658
+ }
659
+ } catch {
660
+ findings.push({
661
+ code: 'migration-status-failed',
662
+ severity: 'error',
663
+ message: 'Database migration status could not be parsed.',
664
+ recovery: 'Run pnpm app:setup and inspect the private migration logs.',
665
+ });
666
+ }
667
+ }
668
+
669
+ const report = {
670
+ schemaVersion: 1,
671
+ status: findings.some((finding) => finding.severity === 'error')
672
+ ? 'error'
673
+ : 'ready',
674
+ profile: runtime?.profile || null,
675
+ capabilities: runtime?.capabilities || null,
676
+ paths:
677
+ paths || statePath
678
+ ? {
679
+ root: paths?.root || null,
680
+ database: paths?.database || null,
681
+ assets: paths?.assets || null,
682
+ state: statePath,
683
+ }
684
+ : null,
685
+ findings,
686
+ secretValuesIncluded: false,
687
+ };
688
+ console.log(JSON.stringify(report, null, 2));
689
+ if (report.status === 'error') process.exitCode = 1;
690
+ }
691
+
692
+ async function backup(operationLock) {
693
+ const runtime = await resolveRuntime();
694
+ if (runtime.profile !== 'local') {
695
+ throw new Error(
696
+ 'This scaffold delegates deployed backups to the selected operator or managed provider.',
697
+ );
698
+ }
699
+ const explicitDestination = commandArgs[0]
700
+ ? assertExternalArtifactPath({
701
+ sourceRoot,
702
+ path: resolve(commandArgs[0]),
703
+ label: 'Backup destination',
704
+ })
705
+ : null;
706
+ const paths = await validateLocalDatabaseStorage({
707
+ appId,
708
+ dataDirectory: process.env.SMRT_DATA_DIR,
709
+ sourceRoot,
710
+ });
711
+ const operatorLease = acquireWriterLease(preparedStateRoot(), {
712
+ operationInstance: operationLock?.instance,
713
+ });
714
+ try {
715
+ const destination = assertExternalArtifactPath({
716
+ sourceRoot,
717
+ path:
718
+ explicitDestination ||
719
+ join(
720
+ dirname(paths.root),
721
+ 'backups',
722
+ `${appId}-${new Date().toISOString().replaceAll(':', '-')}`,
723
+ ),
724
+ label: 'Backup destination',
725
+ });
726
+ ensurePrivateDirectory(dirname(destination));
727
+ try {
728
+ mkdirSync(destination, { mode: 0o700 });
729
+ } catch (error) {
730
+ if (error?.code === 'EEXIST') {
731
+ throw new Error(`Backup destination already exists: ${destination}`);
732
+ }
733
+ throw error;
734
+ }
735
+ try {
736
+ cpSync(paths.root, destination, {
737
+ recursive: true,
738
+ force: false,
739
+ errorOnExist: true,
740
+ });
741
+ } catch (error) {
742
+ rmSync(destination, { recursive: true, force: true });
743
+ throw error;
744
+ }
745
+ console.log(
746
+ JSON.stringify({ schemaVersion: 1, status: 'backed-up', destination }),
747
+ );
748
+ } finally {
749
+ operatorLease.release();
750
+ }
751
+ }
752
+
753
+ async function portability(operation, operationLock) {
754
+ const adapterPath = join(sourceRoot, 'scripts', 'smrt-portability.mjs');
755
+ const adapter = await import(pathToFileURL(adapterPath).href);
756
+ const runtime = await resolveRuntime();
757
+ const environment = runtimeEnvironment(runtime);
758
+ const requestedPath = commandArgs[0] ? resolve(commandArgs[0]) : undefined;
759
+ const artifactPath =
760
+ requestedPath
761
+ ? assertExternalArtifactPath({
762
+ sourceRoot,
763
+ path: requestedPath,
764
+ label:
765
+ operation === 'export' ? 'Export destination' : 'Import source',
766
+ })
767
+ : requestedPath;
768
+ if (runtime.profile === 'local') {
769
+ environment.paths = await validateLocalDatabaseStorage({
770
+ appId,
771
+ dataDirectory: process.env.SMRT_DATA_DIR,
772
+ sourceRoot,
773
+ });
774
+ }
775
+ const context = {
776
+ appId,
777
+ sourceRoot,
778
+ stateRoot: preparedStateRoot(),
779
+ runtime,
780
+ ...environment,
781
+ };
782
+ let operatorLease = null;
783
+ if (operation === 'import') {
784
+ if (runtime.profile === 'local') {
785
+ operatorLease = acquireWriterLease(preparedStateRoot(), {
786
+ operationInstance: operationLock?.instance,
787
+ });
788
+ }
789
+ if (
790
+ runtime.profile !== 'local' &&
791
+ process.env.SMRT_MAINTENANCE_MODE !== 'true'
792
+ ) {
793
+ throw new Error(
794
+ 'Stop deployed web/workers and set SMRT_MAINTENANCE_MODE=true before importing.',
795
+ );
796
+ }
797
+ }
798
+ try {
799
+ const result = await adapter[
800
+ operation === 'export' ? 'exportApplication' : 'importApplication'
801
+ ]({
802
+ ...context,
803
+ path: artifactPath,
804
+ });
805
+ console.log(
806
+ JSON.stringify(
807
+ { schemaVersion: 1, status: `${operation}ed`, ...result },
808
+ null,
809
+ 2,
810
+ ),
811
+ );
812
+ } finally {
813
+ operatorLease?.release();
814
+ }
815
+ }
816
+
817
+ try {
818
+ switch (command) {
819
+ case 'install': {
820
+ await assertLocalOperation('app:install');
821
+ await withOperationLock(preparedStateRoot(), 'install', async (lock) => {
822
+ const report = await setup(lock);
823
+ await start(lock);
824
+ const baseUrl = `http://127.0.0.1:${process.env.PORT || '5173'}/`;
825
+ if (report.onboardingUrl) {
826
+ saveOnboardingLaunch(report.onboardingUrl);
827
+ }
828
+ openBrowser(
829
+ report.onboardingUrl
830
+ ? pathToFileURL(onboardingLaunchPath()).toString()
831
+ : baseUrl,
832
+ );
833
+ });
834
+ break;
835
+ }
836
+ case 'setup':
837
+ await withOperationLock(preparedStateRoot(), command, setup);
838
+ break;
839
+ case 'recover':
840
+ await assertLocalOperation('app:recover');
841
+ await withOperationLock(preparedStateRoot(), command, recoverOnboarding);
842
+ break;
843
+ case 'start':
844
+ await assertLocalOperation('app:start');
845
+ await withOperationLock(preparedStateRoot(), command, start);
846
+ break;
847
+ case 'doctor':
848
+ await doctor();
849
+ break;
850
+ case 'open':
851
+ await open();
852
+ break;
853
+ case 'stop':
854
+ await assertLocalOperation('app:stop');
855
+ await withOperationLock(preparedStateRoot(), command, stop);
856
+ break;
857
+ case 'backup':
858
+ await withOperationLock(preparedStateRoot(), command, backup);
859
+ break;
860
+ case 'export':
861
+ case 'import':
862
+ await withOperationLock(preparedStateRoot(), command, (lock) =>
863
+ portability(command, lock),
864
+ );
865
+ break;
866
+ default:
867
+ throw new Error(`Unknown app operation: ${command}`);
868
+ }
869
+ } catch (error) {
870
+ console.error(
871
+ JSON.stringify({
872
+ schemaVersion: 1,
873
+ status: 'error',
874
+ code: 'operation-failed',
875
+ message:
876
+ error instanceof Error
877
+ ? error.message
878
+ : 'Application operation failed.',
879
+ recovery: 'Run pnpm app:doctor and follow its recovery instructions.',
880
+ secretValuesIncluded: false,
881
+ }),
882
+ );
883
+ process.exitCode = 1;
884
+ }