@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.
- package/AGENTS.md +31 -6
- package/README.md +26 -10
- package/index.js +53 -2
- package/package.json +19 -5
- package/template/.dockerignore +12 -0
- package/template/.env.cloud.example +9 -0
- package/template/.env.example +15 -3
- package/template/.env.self-hosted.example +11 -0
- package/template/AGENTS.md +32 -0
- package/template/Dockerfile +23 -0
- package/template/INSTALL_PROMPT.md +11 -0
- package/template/README.md +74 -32
- package/template/compose.yaml +56 -0
- package/template/gitignore.template +45 -0
- package/template/package.json +30 -15
- package/template/scripts/smrt-app.mjs +884 -0
- package/template/scripts/smrt-operation-lock.mjs +102 -0
- package/template/scripts/smrt-portability-assets.mjs +707 -0
- package/template/scripts/smrt-portability.mjs +470 -0
- package/template/scripts/smrt-prepare-migration.mjs +93 -0
- package/template/scripts/smrt-process.mjs +82 -0
- package/template/scripts/smrt-provider-readiness.mjs +33 -0
- package/template/scripts/smrt-runtime-identity.mjs +238 -0
- package/template/scripts/smrt-vite.mjs +45 -0
- package/template/scripts/smrt-web.mjs +10 -0
- package/template/scripts/smrt-worker.mjs +86 -0
- package/template/scripts/smrt-writer-lease.mjs +199 -0
- package/template/smrt.config.ts +34 -2
- package/template/src/hooks.server.ts +4 -1
- package/template/src/lib/RuntimeDiagnosticsWebMcp.svelte +11 -0
- package/template/src/lib/runtime-diagnostics-webmcp.ts +108 -0
- package/template/src/lib/server/application-runtime.ts +262 -0
- package/template/src/lib/server/runtime-diagnostics.ts +114 -0
- package/template/src/lib/server/smrt.ts +4 -4
- package/template/src/routes/+layout.svelte +2 -0
- package/template/src/routes/api/_runtime/diagnostics/+server.ts +5 -0
- package/template/src/routes/api/_runtime/health/+server.ts +30 -0
- package/template/src/routes/setup/+page.server.ts +96 -0
- package/template/src/routes/setup/+page.svelte +31 -0
- package/template/svelte.config.js +1 -1
- package/template/tsconfig.json +1 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
constants,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { basename, dirname, join, parse, resolve } from 'node:path';
|
|
12
|
+
import { homedir, platform } from 'node:os';
|
|
13
|
+
import {
|
|
14
|
+
encodeApplicationId,
|
|
15
|
+
resolveLocalRuntimePaths,
|
|
16
|
+
validateApplicationId,
|
|
17
|
+
} from '@happyvertical/smrt-app-runtime';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve one stable identity for CLI, development, and app operations.
|
|
21
|
+
* @param {{sourceRoot?: string, packageName?: string, explicitId?: string}} [options]
|
|
22
|
+
*/
|
|
23
|
+
export function resolveApplicationId(options = {}) {
|
|
24
|
+
const sourceRoot = options.sourceRoot || process.cwd();
|
|
25
|
+
let packageName = options.packageName;
|
|
26
|
+
if (!packageName) {
|
|
27
|
+
packageName = JSON.parse(
|
|
28
|
+
readFileSync(join(sourceRoot, 'package.json'), 'utf8'),
|
|
29
|
+
).name;
|
|
30
|
+
}
|
|
31
|
+
if (typeof packageName !== 'string' || packageName.trim() === '') {
|
|
32
|
+
throw new Error('package.json must declare a non-empty package name.');
|
|
33
|
+
}
|
|
34
|
+
return options.explicitId
|
|
35
|
+
? validateApplicationId(options.explicitId)
|
|
36
|
+
: encodeApplicationId(packageName);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* State is derived from the canonical application/data identity. It is not an
|
|
41
|
+
* independent override because every process and operator command must share
|
|
42
|
+
* one lock domain for a given database root.
|
|
43
|
+
* @param {{appId: string, dataDirectory?: string, sourceRoot?: string, platformName?: string, homeDirectory?: string, environment?: Record<string, string | undefined>}} options
|
|
44
|
+
*/
|
|
45
|
+
export function resolveApplicationStateRoot(options) {
|
|
46
|
+
const paths = resolveLocalRuntimePaths({
|
|
47
|
+
appId: options.appId,
|
|
48
|
+
dataDirectory: options.dataDirectory,
|
|
49
|
+
sourceRoot: options.sourceRoot,
|
|
50
|
+
});
|
|
51
|
+
const platformName = options.platformName || platform();
|
|
52
|
+
const homeDirectory = options.homeDirectory || homedir();
|
|
53
|
+
const environment = options.environment || process.env;
|
|
54
|
+
const stateBase =
|
|
55
|
+
platformName === 'darwin'
|
|
56
|
+
? join(homeDirectory, 'Library', 'Application Support')
|
|
57
|
+
: platformName === 'win32'
|
|
58
|
+
? environment.LOCALAPPDATA || homeDirectory
|
|
59
|
+
: environment.XDG_STATE_HOME || join(homeDirectory, '.local', 'state');
|
|
60
|
+
const dataIdentity = createHash('sha256')
|
|
61
|
+
.update(resolve(paths.root))
|
|
62
|
+
.digest('hex')
|
|
63
|
+
.slice(0, 12);
|
|
64
|
+
return resolve(stateBase, `.${options.appId}-${dataIdentity}-state`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {string | undefined} value */
|
|
68
|
+
function databaseTargetIdentity(value) {
|
|
69
|
+
if (!value) return null;
|
|
70
|
+
try {
|
|
71
|
+
const target = new URL(value);
|
|
72
|
+
const sslMode = target.searchParams.get('sslmode');
|
|
73
|
+
target.username = '';
|
|
74
|
+
target.password = '';
|
|
75
|
+
target.search = '';
|
|
76
|
+
target.hash = '';
|
|
77
|
+
if (sslMode) target.searchParams.set('sslmode', sslMode);
|
|
78
|
+
return target.toString();
|
|
79
|
+
} catch {
|
|
80
|
+
return resolve(value);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Secret-safe identity used to reject stale managed processes after profile,
|
|
86
|
+
* provider, database-target, or listener configuration changes.
|
|
87
|
+
* @param {{profile: string, providers: object}} runtime
|
|
88
|
+
* @param {Record<string, string | undefined>} [environment]
|
|
89
|
+
*/
|
|
90
|
+
export function runtimeConfigurationFingerprint(
|
|
91
|
+
runtime,
|
|
92
|
+
environment = process.env,
|
|
93
|
+
) {
|
|
94
|
+
return createHash('sha256')
|
|
95
|
+
.update(
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
profile: runtime.profile,
|
|
98
|
+
providers: runtime.providers,
|
|
99
|
+
databaseTarget: databaseTargetIdentity(environment.DATABASE_URL),
|
|
100
|
+
host: environment.HOST || null,
|
|
101
|
+
port: environment.PORT || null,
|
|
102
|
+
backgroundJobs: environment.SMRT_BACKGROUND_JOBS === 'true',
|
|
103
|
+
readinessModules: {
|
|
104
|
+
authentication: environment.SMRT_AUTH_READINESS_MODULE || null,
|
|
105
|
+
assets: environment.SMRT_ASSETS_READINESS_MODULE || null,
|
|
106
|
+
secrets: environment.SMRT_SECRETS_READINESS_MODULE || null,
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
.digest('hex');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {string} parent @param {string} child */
|
|
114
|
+
function isInside(parent, child) {
|
|
115
|
+
const relative = child.slice(parent.length);
|
|
116
|
+
return (
|
|
117
|
+
child === parent ||
|
|
118
|
+
(child.startsWith(parent) && (relative.startsWith('/') || relative.startsWith('\\')))
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve an operator-selected artifact through its nearest existing ancestor
|
|
124
|
+
* and reject paths that could place application data in or over the checkout.
|
|
125
|
+
* @param {{sourceRoot: string, path: string, label?: string}} options
|
|
126
|
+
*/
|
|
127
|
+
export function assertExternalArtifactPath(options) {
|
|
128
|
+
const canonicalSource = realpathSync(resolve(options.sourceRoot));
|
|
129
|
+
const missingSegments = [];
|
|
130
|
+
let existingAncestor = resolve(options.path);
|
|
131
|
+
while (!existsSync(existingAncestor)) {
|
|
132
|
+
const parent = dirname(existingAncestor);
|
|
133
|
+
if (parent === existingAncestor) break;
|
|
134
|
+
missingSegments.unshift(basename(existingAncestor));
|
|
135
|
+
existingAncestor = parent;
|
|
136
|
+
}
|
|
137
|
+
const canonicalPath = resolve(
|
|
138
|
+
realpathSync(existingAncestor),
|
|
139
|
+
...missingSegments,
|
|
140
|
+
);
|
|
141
|
+
if (
|
|
142
|
+
isInside(canonicalSource, canonicalPath) ||
|
|
143
|
+
isInside(canonicalPath, canonicalSource)
|
|
144
|
+
) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`${options.label || 'Artifact'} must remain outside the source tree.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return canonicalPath;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @param {unknown} error */
|
|
153
|
+
function errorCode(error) {
|
|
154
|
+
return error && typeof error === 'object' && 'code' in error
|
|
155
|
+
? error.code
|
|
156
|
+
: undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create or verify the private, app-bound state/lock directory.
|
|
161
|
+
* @param {{appId: string, dataDirectory?: string, sourceRoot?: string, platformName?: string, homeDirectory?: string, environment?: Record<string, string | undefined>}} options
|
|
162
|
+
*/
|
|
163
|
+
export function prepareApplicationStateRoot(options) {
|
|
164
|
+
const sourceRoot = realpathSync(resolve(options.sourceRoot || process.cwd()));
|
|
165
|
+
const stateRoot = resolveApplicationStateRoot(options);
|
|
166
|
+
if (isInside(sourceRoot, stateRoot) || isInside(stateRoot, sourceRoot)) {
|
|
167
|
+
throw new Error('Application state must remain outside the source tree.');
|
|
168
|
+
}
|
|
169
|
+
const currentUid = process.getuid?.();
|
|
170
|
+
let component = parse(stateRoot).root;
|
|
171
|
+
for (const part of stateRoot.slice(component.length).split(/[\\/]+/).filter(Boolean)) {
|
|
172
|
+
component = join(component, part);
|
|
173
|
+
try {
|
|
174
|
+
const details = lstatSync(component);
|
|
175
|
+
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
176
|
+
throw new Error(`Application state path component is unsafe: ${component}`);
|
|
177
|
+
}
|
|
178
|
+
const sharedStickyRoot = details.uid === 0 && (details.mode & 0o1000) !== 0;
|
|
179
|
+
if (
|
|
180
|
+
currentUid !== undefined &&
|
|
181
|
+
((details.uid !== currentUid && details.uid !== 0) ||
|
|
182
|
+
((details.mode & 0o022) !== 0 && !sharedStickyRoot))
|
|
183
|
+
) {
|
|
184
|
+
throw new Error(`Application state path lacks trusted custody: ${component}`);
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (errorCode(error) !== 'ENOENT') throw error;
|
|
188
|
+
try {
|
|
189
|
+
mkdirSync(component, { mode: 0o700 });
|
|
190
|
+
} catch (mkdirError) {
|
|
191
|
+
if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError;
|
|
192
|
+
}
|
|
193
|
+
const created = lstatSync(component);
|
|
194
|
+
if (
|
|
195
|
+
created.isSymbolicLink() ||
|
|
196
|
+
!created.isDirectory() ||
|
|
197
|
+
(currentUid !== undefined && created.uid !== currentUid) ||
|
|
198
|
+
(created.mode & 0o777) !== 0o700
|
|
199
|
+
) {
|
|
200
|
+
throw new Error(`Application state path component is unsafe: ${component}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const rootDetails = lstatSync(stateRoot);
|
|
205
|
+
if (
|
|
206
|
+
rootDetails.isSymbolicLink() ||
|
|
207
|
+
!rootDetails.isDirectory() ||
|
|
208
|
+
(currentUid !== undefined && rootDetails.uid !== currentUid) ||
|
|
209
|
+
(rootDetails.mode & 0o777) !== 0o700
|
|
210
|
+
) {
|
|
211
|
+
throw new Error('Application state root must be current-user-owned mode 0700.');
|
|
212
|
+
}
|
|
213
|
+
const markerPath = join(stateRoot, `.smrt-state-${validateApplicationId(options.appId)}`);
|
|
214
|
+
let descriptor;
|
|
215
|
+
try {
|
|
216
|
+
descriptor = openSync(
|
|
217
|
+
markerPath,
|
|
218
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
|
219
|
+
0o600,
|
|
220
|
+
);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (errorCode(error) !== 'EEXIST') throw error;
|
|
223
|
+
const marker = lstatSync(markerPath);
|
|
224
|
+
if (
|
|
225
|
+
marker.isSymbolicLink() ||
|
|
226
|
+
!marker.isFile() ||
|
|
227
|
+
marker.size !== 0 ||
|
|
228
|
+
(currentUid !== undefined && marker.uid !== currentUid) ||
|
|
229
|
+
(marker.mode & 0o777) !== 0o600
|
|
230
|
+
) {
|
|
231
|
+
throw new Error('Application state marker is unsafe.');
|
|
232
|
+
}
|
|
233
|
+
} finally {
|
|
234
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
235
|
+
}
|
|
236
|
+
return stateRoot;
|
|
237
|
+
}
|
|
238
|
+
import { createHash } from 'node:crypto';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
function fail(message) {
|
|
6
|
+
writeSync(2, `s-m-r-t Vite launcher: ${message}\n`);
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let packagePath;
|
|
11
|
+
try {
|
|
12
|
+
packagePath = fileURLToPath(import.meta.resolve('vite/package.json'));
|
|
13
|
+
} catch (error) {
|
|
14
|
+
const detail = error instanceof Error ? `: ${error.message}` : '';
|
|
15
|
+
fail(
|
|
16
|
+
`Could not resolve the installed Vite package${detail}. Verify the generated app dependencies and Vite compatibility.`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
const packageRoot = dirname(packagePath);
|
|
20
|
+
let metadata;
|
|
21
|
+
try {
|
|
22
|
+
metadata = JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
23
|
+
} catch {
|
|
24
|
+
fail('The installed Vite package metadata is invalid; reinstall dependencies.');
|
|
25
|
+
}
|
|
26
|
+
const declaredBin =
|
|
27
|
+
typeof metadata.bin === 'string' ? metadata.bin : metadata.bin?.vite;
|
|
28
|
+
if (typeof declaredBin !== 'string' || declaredBin === '') {
|
|
29
|
+
fail('The installed Vite package does not declare its CLI.');
|
|
30
|
+
}
|
|
31
|
+
const entry = resolve(packageRoot, declaredBin);
|
|
32
|
+
const relativeEntry = relative(packageRoot, entry);
|
|
33
|
+
if (
|
|
34
|
+
relativeEntry === '' ||
|
|
35
|
+
relativeEntry.startsWith('..') ||
|
|
36
|
+
isAbsolute(relativeEntry)
|
|
37
|
+
) {
|
|
38
|
+
fail('The installed Vite package declares an unsafe CLI path.');
|
|
39
|
+
}
|
|
40
|
+
if (!existsSync(entry)) {
|
|
41
|
+
fail('The installed Vite package CLI is missing; reinstall dependencies.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
process.argv = [process.execPath, entry, ...process.argv.slice(2)];
|
|
45
|
+
await import(pathToFileURL(entry).href);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const instance = process.argv
|
|
4
|
+
.find((value) => value.startsWith('--smrt-instance='))
|
|
5
|
+
?.slice('--smrt-instance='.length);
|
|
6
|
+
if (!instance || !/^[a-f0-9]{32}$/.test(instance)) {
|
|
7
|
+
throw new Error('A valid application process identity is required.');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
await import('../build/index.js');
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { initializeDeployedApplicationRuntime } from '@happyvertical/smrt-app-runtime';
|
|
4
|
+
import {
|
|
5
|
+
loadConfig,
|
|
6
|
+
resolveConfiguredApplicationRuntime,
|
|
7
|
+
} from '@happyvertical/smrt-config';
|
|
8
|
+
import { getDatabase } from '@happyvertical/sql';
|
|
9
|
+
import { createProviderReadinessProbe } from './smrt-provider-readiness.mjs';
|
|
10
|
+
|
|
11
|
+
await loadConfig({ cache: false });
|
|
12
|
+
const configured = resolveConfiguredApplicationRuntime();
|
|
13
|
+
if (configured.profile === 'local') {
|
|
14
|
+
throw new Error('Local jobs run inline or embedded; a separate worker requires self-hosted or cloud.');
|
|
15
|
+
}
|
|
16
|
+
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required.');
|
|
17
|
+
|
|
18
|
+
const runtime = await initializeDeployedApplicationRuntime({
|
|
19
|
+
profile: configured.profile,
|
|
20
|
+
providers: {
|
|
21
|
+
database: configured.providers.database,
|
|
22
|
+
authentication: configured.providers.authentication,
|
|
23
|
+
tenancy: configured.providers.tenancy,
|
|
24
|
+
assets: configured.providers.assets,
|
|
25
|
+
secrets: configured.providers.secrets,
|
|
26
|
+
jobs: configured.providers.jobs,
|
|
27
|
+
network: configured.providers.network,
|
|
28
|
+
},
|
|
29
|
+
database: {
|
|
30
|
+
engine: 'postgres',
|
|
31
|
+
connect: () =>
|
|
32
|
+
getDatabase({ type: 'postgres', url: process.env.DATABASE_URL }),
|
|
33
|
+
close: async (db) => db.close?.(),
|
|
34
|
+
},
|
|
35
|
+
authentication: {
|
|
36
|
+
provider: configured.providers.authentication.provider,
|
|
37
|
+
readiness: createProviderReadinessProbe('authentication', {
|
|
38
|
+
profile: configured.profile,
|
|
39
|
+
provider: configured.providers.authentication.provider,
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
assets: {
|
|
43
|
+
provider: configured.providers.assets.provider,
|
|
44
|
+
readiness: createProviderReadinessProbe('assets', {
|
|
45
|
+
profile: configured.profile,
|
|
46
|
+
provider: configured.providers.assets.provider,
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
secrets: {
|
|
50
|
+
provider: configured.providers.secrets.provider,
|
|
51
|
+
readiness: createProviderReadinessProbe('secrets', {
|
|
52
|
+
profile: configured.profile,
|
|
53
|
+
provider: configured.providers.secrets.provider,
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const kind = process.argv[2] || 'task';
|
|
59
|
+
const runner =
|
|
60
|
+
kind === 'schedule'
|
|
61
|
+
? await runtime.createScheduleWorker()
|
|
62
|
+
: await runtime.createTaskWorker({
|
|
63
|
+
concurrency: Number.parseInt(
|
|
64
|
+
process.env.SMRT_WORKER_CONCURRENCY || '4',
|
|
65
|
+
10,
|
|
66
|
+
),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await runner.start();
|
|
70
|
+
console.log(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
schemaVersion: 1,
|
|
73
|
+
status: 'ready',
|
|
74
|
+
kind,
|
|
75
|
+
secretValuesIncluded: false,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
let stopping = false;
|
|
80
|
+
async function stop() {
|
|
81
|
+
if (stopping) return;
|
|
82
|
+
stopping = true;
|
|
83
|
+
await runtime.close();
|
|
84
|
+
}
|
|
85
|
+
process.once('SIGINT', () => void stop());
|
|
86
|
+
process.once('SIGTERM', () => void stop());
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
fstatSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/** @param {number} pid */
|
|
15
|
+
function processExists(pid) {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(pid, 0);
|
|
18
|
+
return true;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
const code =
|
|
21
|
+
error && typeof error === 'object' && 'code' in error
|
|
22
|
+
? error.code
|
|
23
|
+
: undefined;
|
|
24
|
+
return code === 'EPERM' || code === 'EACCES';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {{ schemaVersion: number, pid: number, instance: string }} record */
|
|
29
|
+
function validateRecord(record) {
|
|
30
|
+
if (
|
|
31
|
+
record.schemaVersion !== 1 ||
|
|
32
|
+
!Number.isSafeInteger(record.pid) ||
|
|
33
|
+
record.pid < 1 ||
|
|
34
|
+
typeof record.instance !== 'string' ||
|
|
35
|
+
!/^[a-f0-9]{32}$/.test(record.instance)
|
|
36
|
+
) {
|
|
37
|
+
throw new Error('The application writer lease is malformed.');
|
|
38
|
+
}
|
|
39
|
+
return record;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {string | number} pathOrDescriptor */
|
|
43
|
+
function readRecord(pathOrDescriptor) {
|
|
44
|
+
return validateRecord(
|
|
45
|
+
JSON.parse(readFileSync(pathOrDescriptor, 'utf8')),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @param {string} stateRoot */
|
|
50
|
+
export function readActiveWriterLease(stateRoot) {
|
|
51
|
+
const path = join(stateRoot, 'writer.lease');
|
|
52
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
53
|
+
let descriptor;
|
|
54
|
+
try {
|
|
55
|
+
descriptor = openSync(path, 'r');
|
|
56
|
+
const identity = fstatSync(descriptor);
|
|
57
|
+
const record = readRecord(descriptor);
|
|
58
|
+
closeSync(descriptor);
|
|
59
|
+
descriptor = undefined;
|
|
60
|
+
if (processExists(record.pid)) return record;
|
|
61
|
+
const current = lstatSync(path);
|
|
62
|
+
if (current.dev !== identity.dev || current.ino !== identity.ino) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
rmSync(path);
|
|
66
|
+
return null;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
69
|
+
if (
|
|
70
|
+
error &&
|
|
71
|
+
typeof error === 'object' &&
|
|
72
|
+
'code' in error &&
|
|
73
|
+
error.code === 'ENOENT'
|
|
74
|
+
) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
throw new Error(
|
|
78
|
+
'The application writer lease cannot be verified; inspect the private state directory.',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw new Error(
|
|
83
|
+
'The application writer lease changed repeatedly and cannot be verified.',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} stateRoot
|
|
89
|
+
* @param {{ operationInstance?: string }} [options]
|
|
90
|
+
*/
|
|
91
|
+
export function acquireWriterLease(stateRoot, options = {}) {
|
|
92
|
+
mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
|
|
93
|
+
const path = join(stateRoot, 'writer.lease');
|
|
94
|
+
const active = readActiveWriterLease(stateRoot);
|
|
95
|
+
if (active?.pid === process.pid) return { release() {} };
|
|
96
|
+
if (active) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Another application writer is active (process ${active.pid}).`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const instance = randomBytes(16).toString('hex');
|
|
102
|
+
let descriptor;
|
|
103
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
104
|
+
try {
|
|
105
|
+
descriptor = openSync(path, 'wx', 0o600);
|
|
106
|
+
writeFileSync(
|
|
107
|
+
descriptor,
|
|
108
|
+
`${JSON.stringify({ schemaVersion: 1, pid: process.pid, instance })}\n`,
|
|
109
|
+
);
|
|
110
|
+
break;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (descriptor !== undefined) {
|
|
113
|
+
closeSync(descriptor);
|
|
114
|
+
descriptor = undefined;
|
|
115
|
+
}
|
|
116
|
+
if (
|
|
117
|
+
error &&
|
|
118
|
+
typeof error === 'object' &&
|
|
119
|
+
'code' in error &&
|
|
120
|
+
error.code === 'EEXIST'
|
|
121
|
+
) {
|
|
122
|
+
const owner = readActiveWriterLease(stateRoot);
|
|
123
|
+
if (!owner && attempt === 0) continue;
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Another application writer is active${owner ? ` (process ${owner.pid})` : ''}.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
rmSync(path, { force: true });
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (descriptor === undefined) {
|
|
133
|
+
throw new Error('The application writer lease could not be acquired.');
|
|
134
|
+
}
|
|
135
|
+
closeSync(descriptor);
|
|
136
|
+
let released = false;
|
|
137
|
+
const release = () => {
|
|
138
|
+
if (released) return;
|
|
139
|
+
released = true;
|
|
140
|
+
try {
|
|
141
|
+
const current = readRecord(path);
|
|
142
|
+
if (current.pid === process.pid && current.instance === instance) {
|
|
143
|
+
rmSync(path, { force: true });
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// A missing or externally repaired lease is not ours to remove.
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
let operationDescriptor;
|
|
150
|
+
try {
|
|
151
|
+
const operationPath = join(stateRoot, 'operation.lock');
|
|
152
|
+
operationDescriptor = openSync(operationPath, 'r');
|
|
153
|
+
const operationIdentity = fstatSync(operationDescriptor);
|
|
154
|
+
const operation = JSON.parse(readFileSync(operationDescriptor, 'utf8'));
|
|
155
|
+
closeSync(operationDescriptor);
|
|
156
|
+
operationDescriptor = undefined;
|
|
157
|
+
if (
|
|
158
|
+
!Number.isSafeInteger(operation.pid) ||
|
|
159
|
+
operation.pid < 1 ||
|
|
160
|
+
typeof operation.instance !== 'string'
|
|
161
|
+
) {
|
|
162
|
+
throw new Error('The application operation lock cannot be verified.');
|
|
163
|
+
}
|
|
164
|
+
if (!processExists(operation.pid)) {
|
|
165
|
+
const currentIdentity = lstatSync(operationPath);
|
|
166
|
+
if (
|
|
167
|
+
currentIdentity.dev !== operationIdentity.dev ||
|
|
168
|
+
currentIdentity.ino !== operationIdentity.ino
|
|
169
|
+
) {
|
|
170
|
+
throw new Error('The application operation lock changed unexpectedly.');
|
|
171
|
+
}
|
|
172
|
+
rmSync(operationPath);
|
|
173
|
+
} else if (operation.instance !== options.operationInstance) {
|
|
174
|
+
release();
|
|
175
|
+
throw new Error(
|
|
176
|
+
'An application operation is active; wait for it to finish before starting a writer.',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (operationDescriptor !== undefined) closeSync(operationDescriptor);
|
|
181
|
+
if (
|
|
182
|
+
!(
|
|
183
|
+
error &&
|
|
184
|
+
typeof error === 'object' &&
|
|
185
|
+
'code' in error &&
|
|
186
|
+
error.code === 'ENOENT'
|
|
187
|
+
)
|
|
188
|
+
) {
|
|
189
|
+
if (error instanceof SyntaxError) {
|
|
190
|
+
release();
|
|
191
|
+
throw new Error('The application operation lock cannot be verified.');
|
|
192
|
+
}
|
|
193
|
+
release();
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
process.once('exit', release);
|
|
198
|
+
return { release };
|
|
199
|
+
}
|
package/template/smrt.config.ts
CHANGED
|
@@ -5,7 +5,33 @@
|
|
|
5
5
|
* See: https://github.com/happyvertical/smrt
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { resolveLocalRuntimePaths } from '@happyvertical/smrt-app-runtime';
|
|
9
|
+
import { resolveApplicationId } from './scripts/smrt-runtime-identity.mjs';
|
|
10
|
+
|
|
11
|
+
const profile = (process.env.SMRT_RUNTIME_PROFILE || 'local') as
|
|
12
|
+
| 'local'
|
|
13
|
+
| 'self-hosted'
|
|
14
|
+
| 'cloud';
|
|
15
|
+
const appId = resolveApplicationId({
|
|
16
|
+
sourceRoot: process.cwd(),
|
|
17
|
+
explicitId: process.env.SMRT_APP_ID,
|
|
18
|
+
});
|
|
19
|
+
const localDatabase =
|
|
20
|
+
profile === 'local'
|
|
21
|
+
? resolveLocalRuntimePaths({
|
|
22
|
+
appId,
|
|
23
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
24
|
+
sourceRoot: process.cwd(),
|
|
25
|
+
}).database
|
|
26
|
+
: undefined;
|
|
27
|
+
|
|
8
28
|
export default {
|
|
29
|
+
// This is the single infrastructure selector for the application. Domain
|
|
30
|
+
// objects and generated REST/CLI/MCP/WebMCP surfaces do not branch on it.
|
|
31
|
+
runtime: {
|
|
32
|
+
profile,
|
|
33
|
+
},
|
|
34
|
+
|
|
9
35
|
// Agent/developer knowledge artifacts are generated by smrtPlugin().
|
|
10
36
|
// HTTP API routes stay off unless explicitly enabled for a dev/admin-only workflow.
|
|
11
37
|
knowledge: {
|
|
@@ -24,8 +50,14 @@ export default {
|
|
|
24
50
|
// CLI configuration
|
|
25
51
|
cli: {
|
|
26
52
|
database: {
|
|
27
|
-
type:
|
|
28
|
-
|
|
53
|
+
type:
|
|
54
|
+
profile === 'local' ? 'sqlite' : 'postgres',
|
|
55
|
+
// `smrt-app` sets DATABASE_URL to the profile-owned data path before
|
|
56
|
+
// migrations. Deployed profiles require an operator-owned URL.
|
|
57
|
+
url:
|
|
58
|
+
profile === 'local'
|
|
59
|
+
? process.env.DATABASE_URL || localDatabase
|
|
60
|
+
: process.env.DATABASE_URL,
|
|
29
61
|
},
|
|
30
62
|
verbose: false,
|
|
31
63
|
},
|
|
@@ -14,14 +14,17 @@
|
|
|
14
14
|
|
|
15
15
|
import { getCurrentTenant, enableTenancy } from '@happyvertical/smrt-tenancy';
|
|
16
16
|
import { createSessionHandler } from '@happyvertical/smrt-users/sveltekit';
|
|
17
|
-
import type { Handle } from '@sveltejs/kit';
|
|
17
|
+
import type { Handle, ServerInit } from '@sveltejs/kit';
|
|
18
18
|
import { sequence } from '@sveltejs/kit/hooks';
|
|
19
19
|
|
|
20
20
|
import { getSmrtConfig } from '$lib/server/smrt';
|
|
21
|
+
import { ensureApplicationRuntimeReady } from '$lib/server/application-runtime';
|
|
21
22
|
import { resolveTenant } from '$lib/server/tenancy';
|
|
22
23
|
|
|
23
24
|
enableTenancy();
|
|
24
25
|
|
|
26
|
+
export const init: ServerInit = ensureApplicationRuntimeReady;
|
|
27
|
+
|
|
25
28
|
const tenantSelectionHandle: Handle = async ({ event, resolve }) => {
|
|
26
29
|
const selection = await resolveTenant(event);
|
|
27
30
|
event.locals.selectedTenantId = selection.tenantId;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { registerRuntimeDiagnosticsWebMcp } from './runtime-diagnostics-webmcp.js';
|
|
3
|
+
|
|
4
|
+
let { enabled = true }: { enabled?: boolean } = $props();
|
|
5
|
+
|
|
6
|
+
$effect(() => {
|
|
7
|
+
if (!enabled) return;
|
|
8
|
+
const owner = registerRuntimeDiagnosticsWebMcp();
|
|
9
|
+
return () => owner?.dispose();
|
|
10
|
+
});
|
|
11
|
+
</script>
|