@happyvertical/smrt-cli 0.53.2 → 0.53.4
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 +1 -0
- package/README.md +40 -0
- package/agents/app-commands.md +93 -0
- package/bin/smrt-web.mjs +19 -0
- package/bin/smrt.js +18 -12
- package/dist/{ApiKey-BZmbOTnL-nvcrCRDQ.js → ApiKey-BZmbOTnL-DPPF6Swn.js} +2 -2
- package/dist/ApiKeyCollection-Bfb_nw81-B1-2YKj8.js +2 -0
- package/dist/AuditLogCollection-B1YonCAP-DP4PCCw-.js +2 -0
- package/dist/{NostrIdentity-n70E0vck-BYlxfKXV.js → NostrIdentity-n70E0vck-D60BSg6H.js} +3 -3
- package/dist/NostrIdentityCollection-Ca74dkaL-C-v57o9N.js +2 -0
- package/dist/{OidcIdentity-Cb1bxreM-DDXZ2ULx.js → OidcIdentity-Cb1bxreM-B2eV2ahq.js} +2 -2
- package/dist/OidcIdentityCollection-TV8VdK69-BodvOtPJ.js +2 -0
- package/dist/ProfileAssetCollection-B9hacUJa-B7PLe0ld.js +2 -0
- package/dist/{ProfileCollection-DuJT7Mum-CEPfbh20.js → ProfileCollection-DuJT7Mum-B3Uhqphs.js} +26 -26
- package/dist/ProfileLinkCollection-4uNURz32-CLY1cBnn.js +2 -0
- package/dist/ProfileMetadataCollection-DQhbfEfa-Bzv-E4ep.js +2 -0
- package/dist/{ProfileMetafieldCollection-CoBuM_5G-DiMAQiDg.js → ProfileMetafieldCollection-CoBuM_5G-D-VqQuY1.js} +1 -1
- package/dist/{ProfileRelationshipCollection-DUVgaIqf-CUh6lQdy.js → ProfileRelationshipCollection-DUVgaIqf-gEkDBmkh.js} +1 -1
- package/dist/{ProfileRelationshipTermCollection-DtA9OM23-Sn18A9Vz.js → ProfileRelationshipTermCollection-DtA9OM23-DI_awdol.js} +1 -1
- package/dist/{ProfileRelationshipType-BRiRka6t-C0Sh1CTk.js → ProfileRelationshipType-BRiRka6t-Ci-qvFhm.js} +1 -1
- package/dist/{ProfileRelationshipTypeCollection-BdXwd7Kf-DEnQtZuK.js → ProfileRelationshipTypeCollection-BdXwd7Kf-CaQMLH9f.js} +1 -1
- package/dist/app/cli.d.ts +44 -0
- package/dist/app/cli.d.ts.map +1 -0
- package/dist/app/errors.d.ts +22 -0
- package/dist/app/errors.d.ts.map +1 -0
- package/dist/app/identity.d.ts +24 -0
- package/dist/app/identity.d.ts.map +1 -0
- package/dist/app/index.d.ts +19 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/launchers.d.ts +40 -0
- package/dist/app/launchers.d.ts.map +1 -0
- package/dist/app/operations.d.ts +28 -0
- package/dist/app/operations.d.ts.map +1 -0
- package/dist/app/portability-assets.d.ts +151 -0
- package/dist/app/portability-assets.d.ts.map +1 -0
- package/dist/app/portability.d.ts +117 -0
- package/dist/app/portability.d.ts.map +1 -0
- package/dist/app/process-record.d.ts +54 -0
- package/dist/app/process-record.d.ts.map +1 -0
- package/dist/app/runtime.d.ts +83 -0
- package/dist/app/runtime.d.ts.map +1 -0
- package/dist/app.d.ts +1 -0
- package/dist/app.js +4 -0
- package/dist/cli-BRFsobey.js +1275 -0
- package/dist/cli-generator.d.ts.map +1 -1
- package/dist/commands/utilities.d.ts.map +1 -1
- package/dist/{commands-D6UUd6cJ.js → commands-dFz1e9A6.js} +17 -288
- package/dist/{dist-BwlARYH5.js → dist-Berwe80z.js} +4 -4
- package/dist/{dist-vvYmwpFv.js → dist-C2BqYVu7.js} +27 -27
- package/dist/{dist-BCAkEXaz.js → dist-D4BNILLN.js} +3 -3
- package/dist/{dist-BGsb09DN.js → dist-EJ63xUEp.js} +2 -2
- package/dist/index.js +14 -9
- package/dist/mcp-apps-packaging-CixRnxz_.js +286 -0
- package/dist/{oidcProvisioningPrimitives-D9OYu5-F-D5PxeL32.js → oidcProvisioningPrimitives-D9OYu5-F-B-kYsBTM.js} +4 -4
- package/dist/portability-DmQWSSXA.js +859 -0
- package/dist/resolveIdentity-B-PK6RwU-BmAU1Kse.js +2 -0
- package/package.json +14 -9
- package/dist/ApiKeyCollection-Bfb_nw81-CZVWaFPV.js +0 -2
- package/dist/AuditLogCollection-B1YonCAP-CnMHEquA.js +0 -2
- package/dist/NostrIdentityCollection-Ca74dkaL-C1Q9BNQc.js +0 -2
- package/dist/OidcIdentityCollection-TV8VdK69-RZ2ocmoN.js +0 -2
- package/dist/ProfileAssetCollection-B9hacUJa-C1qMe0_q.js +0 -2
- package/dist/ProfileLinkCollection-4uNURz32-GWzU0-kb.js +0 -2
- package/dist/ProfileMetadataCollection-DQhbfEfa-Dog-9nKM.js +0 -2
- package/dist/resolveIdentity-B-PK6RwU-CkUCBn4e.js +0 -2
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
|
|
2
|
+
import { accessSync, constants, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { acquireWriterLease, createProviderReadinessProbe, findInstalledPackage, prepareApplicationStateRoot, readActiveWriterLease, resolveApplicationId, resolveApplicationStateRoot, runtimeConfigurationFingerprint, withOperationLock } from "@happyvertical/smrt-app-runtime";
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { platform } from "node:os";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
//#region src/app/errors.ts
|
|
10
|
+
/**
|
|
11
|
+
* Shared error helpers for the `smrt app` command group.
|
|
12
|
+
*/
|
|
13
|
+
/** Read a Node `code` from an unknown thrown value. */
|
|
14
|
+
function errorCode(error) {
|
|
15
|
+
return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
16
|
+
}
|
|
17
|
+
/** Error carrying a process exit code from a failed child command. */
|
|
18
|
+
var AppCommandError = class extends Error {
|
|
19
|
+
exitCode;
|
|
20
|
+
constructor(message, exitCode = 1, options) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "AppCommandError";
|
|
23
|
+
this.exitCode = exitCode;
|
|
24
|
+
if (options && "cause" in options) Object.defineProperty(this, "cause", {
|
|
25
|
+
value: options.cause,
|
|
26
|
+
configurable: true
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
/** Minimum length before an environment value is treated as redactable. */
|
|
31
|
+
var MIN_REDACTED_VALUE_LENGTH = 8;
|
|
32
|
+
/** Environment names whose values must never reach operator output. */
|
|
33
|
+
var SECRET_ENVIRONMENT_NAME = /(?:^|_)(?:DATABASE_URL|URL|DSN|TOKEN|SECRET|PASSWORD|PASSWD|KEY|CREDENTIALS?|AUTH)(?:_|$)/i;
|
|
34
|
+
/**
|
|
35
|
+
* Remove secret material from an operator-facing message.
|
|
36
|
+
*
|
|
37
|
+
* Errors raised by drivers, config loaders, and providers routinely echo a
|
|
38
|
+
* connection string, credential, or bootstrap token. Redaction covers:
|
|
39
|
+
* literal values of secret-named environment variables, URL userinfo,
|
|
40
|
+
* `token=`/`password=`/`secret=` query values, and bearer tokens.
|
|
41
|
+
*/
|
|
42
|
+
function redactSecrets(message, environment = process.env) {
|
|
43
|
+
let redacted = message;
|
|
44
|
+
const values = Object.entries(environment).filter(([name, value]) => typeof value === "string" && value.length >= MIN_REDACTED_VALUE_LENGTH && SECRET_ENVIRONMENT_NAME.test(name)).map(([, value]) => value).sort((left, right) => right.length - left.length);
|
|
45
|
+
for (const value of values) redacted = redacted.replaceAll(value, "[redacted]");
|
|
46
|
+
return redacted.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:'"]*(?::[^\s/@'"]*)?@/gi, "$1[redacted]@").replace(/([?&;](?:token|password|secret|key|access_token)=)[^&\s"'<>]+/gi, "$1[redacted]").replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/g, "Bearer [redacted]");
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/app/identity.ts
|
|
50
|
+
/**
|
|
51
|
+
* Application identity, private state root, and operator-artifact path
|
|
52
|
+
* custody for the `smrt app` command group.
|
|
53
|
+
*
|
|
54
|
+
* Identity, configuration fingerprint, and the private state root are
|
|
55
|
+
* `@happyvertical/smrt-app-runtime`'s single implementation (re-exported
|
|
56
|
+
* here), so the running web process and these commands can never derive
|
|
57
|
+
* different identities or lock domains. Operator-artifact path custody is
|
|
58
|
+
* CLI-only and stays here.
|
|
59
|
+
*/
|
|
60
|
+
function isInside(parent, child) {
|
|
61
|
+
const relative = child.slice(parent.length);
|
|
62
|
+
return child === parent || child.startsWith(parent) && (relative.startsWith("/") || relative.startsWith("\\"));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve an operator-selected artifact through its nearest existing ancestor
|
|
66
|
+
* and reject paths that could place application data in or over the checkout.
|
|
67
|
+
*/
|
|
68
|
+
function assertExternalArtifactPath(options) {
|
|
69
|
+
const canonicalSource = realpathSync(resolve(options.sourceRoot));
|
|
70
|
+
const missingSegments = [];
|
|
71
|
+
let existingAncestor = resolve(options.path);
|
|
72
|
+
while (!existsSync(existingAncestor)) {
|
|
73
|
+
const parent = dirname(existingAncestor);
|
|
74
|
+
if (parent === existingAncestor) break;
|
|
75
|
+
missingSegments.unshift(basename(existingAncestor));
|
|
76
|
+
existingAncestor = parent;
|
|
77
|
+
}
|
|
78
|
+
const canonicalPath = resolve(realpathSync(existingAncestor), ...missingSegments);
|
|
79
|
+
if (isInside(canonicalSource, canonicalPath) || isInside(canonicalPath, canonicalSource)) throw new Error(`${options.label || "Artifact"} must remain outside the source tree.`);
|
|
80
|
+
return canonicalPath;
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/app/runtime.ts
|
|
84
|
+
/**
|
|
85
|
+
* Shared per-invocation context for the `smrt app` command group: source
|
|
86
|
+
* root, application identity, I/O, child-process runners, and the
|
|
87
|
+
* profile-aware runtime environment.
|
|
88
|
+
*/
|
|
89
|
+
/** Locate this package's root (`…/smrt-cli`) from the running module. */
|
|
90
|
+
function cliPackageRoot() {
|
|
91
|
+
let directory = dirname(fileURLToPath(import.meta.url));
|
|
92
|
+
for (let depth = 0; depth < 5; depth += 1) {
|
|
93
|
+
const candidate = join(directory, "package.json");
|
|
94
|
+
if (existsSync(candidate)) try {
|
|
95
|
+
if (JSON.parse(readFileSync(candidate, "utf8")).name === "@happyvertical/smrt-cli") return directory;
|
|
96
|
+
} catch {}
|
|
97
|
+
const parent = dirname(directory);
|
|
98
|
+
if (parent === directory) break;
|
|
99
|
+
directory = parent;
|
|
100
|
+
}
|
|
101
|
+
throw new Error("The smrt CLI package root could not be located.");
|
|
102
|
+
}
|
|
103
|
+
/** Absolute path of the `smrt` executable shipped with this package. */
|
|
104
|
+
function cliExecutablePath() {
|
|
105
|
+
return join(cliPackageRoot(), "bin", "smrt.js");
|
|
106
|
+
}
|
|
107
|
+
/** Absolute path of the web launcher `start` spawns. */
|
|
108
|
+
function webLauncherPath() {
|
|
109
|
+
return join(cliPackageRoot(), "bin", "smrt-web.mjs");
|
|
110
|
+
}
|
|
111
|
+
/** Load `<sourceRoot>/.env` without overriding the shell; a missing file is fine. */
|
|
112
|
+
function loadSourceEnvironment(sourceRoot) {
|
|
113
|
+
try {
|
|
114
|
+
process.loadEnvFile(join(sourceRoot, ".env"));
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function runChild(sourceRoot, binary, args, options = {}) {
|
|
120
|
+
const result = spawnSync(binary, args, {
|
|
121
|
+
cwd: sourceRoot,
|
|
122
|
+
env: options.env || process.env,
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
shell: options.shell || false,
|
|
125
|
+
stdio: options.capture ? [
|
|
126
|
+
"ignore",
|
|
127
|
+
"pipe",
|
|
128
|
+
"pipe"
|
|
129
|
+
] : "inherit"
|
|
130
|
+
});
|
|
131
|
+
if (!options.allowFailure && (result.error || result.status !== 0)) throw new AppCommandError(`${options.label || binary} failed with exit code ${result.status ?? 1}.`, result.status ?? 1, { cause: result.error });
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
/** Default package-manager runner: honours a pnpm `npm_execpath`. */
|
|
135
|
+
function createPackageManagerRunner(sourceRoot) {
|
|
136
|
+
return (args, options = {}) => {
|
|
137
|
+
const npmExecPath = process.env.npm_execpath;
|
|
138
|
+
const pnpmExecPath = npmExecPath && basename(npmExecPath).toLowerCase().startsWith("pnpm") ? npmExecPath : null;
|
|
139
|
+
return pnpmExecPath ? runChild(sourceRoot, process.execPath, [pnpmExecPath, ...args], options) : runChild(sourceRoot, platform() === "win32" ? "pnpm.cmd" : "pnpm", args, {
|
|
140
|
+
...options,
|
|
141
|
+
shell: platform() === "win32"
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Default self-runner: this package's own `smrt` executable. */
|
|
146
|
+
function createSmrtRunner(sourceRoot) {
|
|
147
|
+
return (args, options = {}) => runChild(sourceRoot, process.execPath, [cliExecutablePath(), ...args], options);
|
|
148
|
+
}
|
|
149
|
+
/** Default browser opener (`SMRT_OPEN_STUB` writes the URL to a file instead). */
|
|
150
|
+
function createBrowserOpener(sourceRoot) {
|
|
151
|
+
return (url) => {
|
|
152
|
+
if (process.env.SMRT_OPEN_STUB) {
|
|
153
|
+
writeFileSync(resolve(process.env.SMRT_OPEN_STUB), `${url}\n`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const [binary, args] = platform() === "darwin" ? ["open", [url]] : platform() === "win32" ? ["cmd", [
|
|
157
|
+
"/c",
|
|
158
|
+
"start",
|
|
159
|
+
"",
|
|
160
|
+
url
|
|
161
|
+
]] : ["xdg-open", [url]];
|
|
162
|
+
runChild(sourceRoot, binary, args);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** Default runtime resolver: reload `smrt.config` without the cache. */
|
|
166
|
+
async function resolveConfiguredRuntime(sourceRoot) {
|
|
167
|
+
const { loadConfig, resolveConfiguredApplicationRuntime } = await import("@happyvertical/smrt-config");
|
|
168
|
+
await loadConfig({
|
|
169
|
+
cache: false,
|
|
170
|
+
searchFrom: sourceRoot
|
|
171
|
+
});
|
|
172
|
+
return resolveConfiguredApplicationRuntime();
|
|
173
|
+
}
|
|
174
|
+
/** Build the default dependency set for `sourceRoot`. */
|
|
175
|
+
async function defaultDependencies(sourceRoot) {
|
|
176
|
+
return {
|
|
177
|
+
runtime: await import("@happyvertical/smrt-app-runtime"),
|
|
178
|
+
resolveRuntime: resolveConfiguredRuntime,
|
|
179
|
+
runPackageManager: createPackageManagerRunner(sourceRoot),
|
|
180
|
+
runSmrt: createSmrtRunner(sourceRoot),
|
|
181
|
+
openBrowser: createBrowserOpener(sourceRoot),
|
|
182
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
183
|
+
sleep: (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)),
|
|
184
|
+
signal: (pid, signal) => {
|
|
185
|
+
process.kill(pid, signal);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Resolve the application id from `<sourceRoot>/package.json` and `SMRT_APP_ID`. */
|
|
190
|
+
function resolveContextApplicationId(sourceRoot) {
|
|
191
|
+
return resolveApplicationId({
|
|
192
|
+
sourceRoot,
|
|
193
|
+
packageName: JSON.parse(readFileSync(join(sourceRoot, "package.json"), "utf8")).name,
|
|
194
|
+
explicitId: process.env.SMRT_APP_ID
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/** Create or verify the private state root for this invocation. */
|
|
198
|
+
function preparedStateRoot(context) {
|
|
199
|
+
return prepareApplicationStateRoot({
|
|
200
|
+
appId: context.appId,
|
|
201
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
202
|
+
sourceRoot: context.sourceRoot
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** Compute the environment a web/migration child of this app runs with. */
|
|
206
|
+
function runtimeEnvironment(context, runtime) {
|
|
207
|
+
const env = {
|
|
208
|
+
...process.env,
|
|
209
|
+
SMRT_APP_ID: context.appId,
|
|
210
|
+
SMRT_RUNTIME_PROFILE: runtime.profile,
|
|
211
|
+
HOST: runtime.profile === "local" ? "127.0.0.1" : process.env.HOST || "0.0.0.0",
|
|
212
|
+
PORT: process.env.PORT || "5173"
|
|
213
|
+
};
|
|
214
|
+
if (runtime.profile === "local") {
|
|
215
|
+
const paths = context.deps.runtime.resolveLocalRuntimePaths({
|
|
216
|
+
appId: context.appId,
|
|
217
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
218
|
+
sourceRoot: context.sourceRoot
|
|
219
|
+
});
|
|
220
|
+
env.DATABASE_TYPE = "sqlite";
|
|
221
|
+
env.DATABASE_URL = paths.database;
|
|
222
|
+
env.SMRT_ASSETS_DIR = paths.assets;
|
|
223
|
+
return {
|
|
224
|
+
env,
|
|
225
|
+
paths,
|
|
226
|
+
assetRoot: paths.assets
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (!process.env.DATABASE_URL) throw new Error(`${runtime.profile} requires DATABASE_URL; copy the matching env example and configure providers.`);
|
|
230
|
+
env.DATABASE_TYPE = "postgres";
|
|
231
|
+
let assetRoot = null;
|
|
232
|
+
if (runtime.providers.assets.provider === "local-files") {
|
|
233
|
+
if (!process.env.SMRT_ASSETS_DIR || !isAbsolute(process.env.SMRT_ASSETS_DIR)) throw new Error("Filesystem-backed deployed profiles require an absolute SMRT_ASSETS_DIR outside the source tree.");
|
|
234
|
+
assetRoot = assertExternalArtifactPath({
|
|
235
|
+
sourceRoot: context.sourceRoot,
|
|
236
|
+
path: process.env.SMRT_ASSETS_DIR,
|
|
237
|
+
label: "Asset storage root"
|
|
238
|
+
});
|
|
239
|
+
env.SMRT_ASSETS_DIR = assetRoot;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
env,
|
|
243
|
+
paths: null,
|
|
244
|
+
assetRoot
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/app/launchers.ts
|
|
249
|
+
/**
|
|
250
|
+
* Process wrappers: explicit schema preparation (`migrate`), deployed job
|
|
251
|
+
* workers (`worker`), and the Vite launcher (`dev`, `build`, `vite`).
|
|
252
|
+
*
|
|
253
|
+
* Ported from the template's `scripts/smrt-prepare-migration.mjs`,
|
|
254
|
+
* `scripts/smrt-worker.mjs`, `scripts/smrt-vite.mjs`, and
|
|
255
|
+
* `scripts/smrt-mcp-apps.mjs`.
|
|
256
|
+
*/
|
|
257
|
+
/**
|
|
258
|
+
* Prepare the schema explicitly: under the operation lock, refuse while a
|
|
259
|
+
* local writer is live, establish local storage custody without creating
|
|
260
|
+
* schema or bootstrap records, then run `smrt db:migrate`.
|
|
261
|
+
*/
|
|
262
|
+
async function prepareMigration(context) {
|
|
263
|
+
const runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
264
|
+
const stateRoot = preparedStateRoot(context);
|
|
265
|
+
await withOperationLock(stateRoot, "db:migrate", async () => {
|
|
266
|
+
const env = {
|
|
267
|
+
...process.env,
|
|
268
|
+
SMRT_APP_ID: context.appId
|
|
269
|
+
};
|
|
270
|
+
if (runtime.profile === "local") {
|
|
271
|
+
if (readActiveWriterLease(stateRoot)) throw new Error("Stop the local application before preparing its schema.");
|
|
272
|
+
const paths = context.deps.runtime.resolveLocalRuntimePaths({
|
|
273
|
+
appId: context.appId,
|
|
274
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
275
|
+
sourceRoot: context.sourceRoot
|
|
276
|
+
});
|
|
277
|
+
await context.deps.runtime.prepareLocalDatabaseStorage({
|
|
278
|
+
appId: context.appId,
|
|
279
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
280
|
+
sourceRoot: context.sourceRoot
|
|
281
|
+
});
|
|
282
|
+
env.DATABASE_TYPE = "sqlite";
|
|
283
|
+
env.DATABASE_URL = paths.database;
|
|
284
|
+
env.SMRT_ASSETS_DIR = paths.assets;
|
|
285
|
+
}
|
|
286
|
+
const result = context.deps.runSmrt(["db:migrate"], {
|
|
287
|
+
env,
|
|
288
|
+
allowFailure: true
|
|
289
|
+
});
|
|
290
|
+
if (result.error || result.status !== 0) throw new AppCommandError("s-m-r-t database migration failed.", result.status ?? 1, { cause: result.error });
|
|
291
|
+
});
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
var WORKER_USAGE = "Usage: smrt app worker [task|schedule]";
|
|
295
|
+
/** Parse `smrt app worker [kind]`, refusing anything but `task`/`schedule`. */
|
|
296
|
+
function parseWorkerKind(args) {
|
|
297
|
+
if (args.length > 1) throw new AppCommandError(WORKER_USAGE, 2);
|
|
298
|
+
const kind = args[0] ?? "task";
|
|
299
|
+
if (kind !== "task" && kind !== "schedule") throw new AppCommandError(WORKER_USAGE, 2);
|
|
300
|
+
return kind;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Start a deployed job worker. Resolves once the runner is started; the
|
|
304
|
+
* process stays alive until SIGINT/SIGTERM closes the runtime.
|
|
305
|
+
*/
|
|
306
|
+
async function runWorker(context, args) {
|
|
307
|
+
const kind = parseWorkerKind(args);
|
|
308
|
+
const configured = await context.deps.resolveRuntime(context.sourceRoot);
|
|
309
|
+
if (configured.profile === "local") throw new Error("Local jobs run inline or embedded; a separate worker requires self-hosted or cloud.");
|
|
310
|
+
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required.");
|
|
311
|
+
const registration = join(context.sourceRoot, ".smrt", "runtime", "register.js");
|
|
312
|
+
if (!existsSync(registration)) throw new Error("Missing .smrt/runtime/register.js: run pnpm build before starting a worker.");
|
|
313
|
+
await import(pathToFileURL(registration).href);
|
|
314
|
+
const probe = (component) => createProviderReadinessProbe(component, {
|
|
315
|
+
profile: configured.profile,
|
|
316
|
+
provider: configured.providers[component].provider
|
|
317
|
+
}, { sourceRoot: context.sourceRoot });
|
|
318
|
+
const runtime = await context.deps.runtime.initializeDeployedApplicationRuntime({
|
|
319
|
+
profile: configured.profile,
|
|
320
|
+
providers: {
|
|
321
|
+
database: configured.providers.database,
|
|
322
|
+
authentication: configured.providers.authentication,
|
|
323
|
+
tenancy: configured.providers.tenancy,
|
|
324
|
+
assets: configured.providers.assets,
|
|
325
|
+
secrets: configured.providers.secrets,
|
|
326
|
+
jobs: configured.providers.jobs,
|
|
327
|
+
network: configured.providers.network
|
|
328
|
+
},
|
|
329
|
+
database: {
|
|
330
|
+
engine: "postgres",
|
|
331
|
+
connect: async () => {
|
|
332
|
+
const { getDatabase } = await import("@happyvertical/sql");
|
|
333
|
+
return await getDatabase({
|
|
334
|
+
type: "postgres",
|
|
335
|
+
url: process.env.DATABASE_URL
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
close: async (db) => {
|
|
339
|
+
await db.close?.();
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
authentication: {
|
|
343
|
+
provider: configured.providers.authentication.provider,
|
|
344
|
+
readiness: probe("authentication")
|
|
345
|
+
},
|
|
346
|
+
assets: {
|
|
347
|
+
provider: configured.providers.assets.provider,
|
|
348
|
+
readiness: probe("assets")
|
|
349
|
+
},
|
|
350
|
+
secrets: {
|
|
351
|
+
provider: configured.providers.secrets.provider,
|
|
352
|
+
readiness: probe("secrets")
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
await (kind === "schedule" ? await runtime.createScheduleWorker() : await runtime.createTaskWorker({ concurrency: Number.parseInt(process.env.SMRT_WORKER_CONCURRENCY || "4", 10) })).start();
|
|
356
|
+
context.io.stdout(`${JSON.stringify({
|
|
357
|
+
schemaVersion: 1,
|
|
358
|
+
status: "ready",
|
|
359
|
+
kind,
|
|
360
|
+
secretValuesIncluded: false
|
|
361
|
+
})}\n`);
|
|
362
|
+
let stopping = false;
|
|
363
|
+
const stop = async () => {
|
|
364
|
+
if (stopping) return;
|
|
365
|
+
stopping = true;
|
|
366
|
+
await runtime.close();
|
|
367
|
+
};
|
|
368
|
+
process.once("SIGINT", () => void stop());
|
|
369
|
+
process.once("SIGTERM", () => void stop());
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
/** Failure of the Vite launcher; rendered as `s-m-r-t Vite launcher: …`. */
|
|
373
|
+
var ViteLauncherError = class extends Error {};
|
|
374
|
+
/** Resolve the application's installed Vite CLI entry, refusing unsafe paths. */
|
|
375
|
+
function resolveViteEntry(sourceRoot) {
|
|
376
|
+
const installed = findInstalledPackage(sourceRoot, "vite");
|
|
377
|
+
if (!installed) throw new ViteLauncherError("Could not resolve the installed Vite package. Verify the generated app dependencies and Vite compatibility.");
|
|
378
|
+
const packagePath = join(realpathSync(installed), "package.json");
|
|
379
|
+
const packageRoot = dirname(packagePath);
|
|
380
|
+
let metadata;
|
|
381
|
+
try {
|
|
382
|
+
metadata = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
383
|
+
} catch {
|
|
384
|
+
throw new ViteLauncherError("The installed Vite package metadata is invalid; reinstall dependencies.");
|
|
385
|
+
}
|
|
386
|
+
const declaredBin = typeof metadata.bin === "string" ? metadata.bin : metadata.bin?.vite;
|
|
387
|
+
if (typeof declaredBin !== "string" || declaredBin === "") throw new ViteLauncherError("The installed Vite package does not declare its CLI.");
|
|
388
|
+
const entry = resolve(packageRoot, declaredBin);
|
|
389
|
+
const relativeEntry = relative(packageRoot, entry);
|
|
390
|
+
if (relativeEntry === "" || relativeEntry.startsWith("..") || isAbsolute(relativeEntry)) throw new ViteLauncherError("The installed Vite package declares an unsafe CLI path.");
|
|
391
|
+
if (!existsSync(entry)) throw new ViteLauncherError("The installed Vite package CLI is missing; reinstall dependencies.");
|
|
392
|
+
return entry;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Run the application's Vite CLI in this process with `args` verbatim. The
|
|
396
|
+
* caller has already loaded `.env` without overriding the shell.
|
|
397
|
+
*/
|
|
398
|
+
async function launchVite(context, args) {
|
|
399
|
+
let entry;
|
|
400
|
+
try {
|
|
401
|
+
entry = resolveViteEntry(context.sourceRoot);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error instanceof ViteLauncherError) {
|
|
404
|
+
context.io.stderr(`s-m-r-t Vite launcher: ${error.message}\n`);
|
|
405
|
+
return 1;
|
|
406
|
+
}
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
process.argv = [
|
|
410
|
+
process.execPath,
|
|
411
|
+
entry,
|
|
412
|
+
...args
|
|
413
|
+
];
|
|
414
|
+
await import(pathToFileURL(entry).href);
|
|
415
|
+
return 0;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Validate opt-in portable MCP Apps metadata (`./mcp-apps`) when present.
|
|
419
|
+
* Returns the exit code; findings go to stderr.
|
|
420
|
+
*/
|
|
421
|
+
async function validateMcpAppsIfPresent(context) {
|
|
422
|
+
const root = join(context.sourceRoot, "mcp-apps");
|
|
423
|
+
if (!existsSync(root)) return 0;
|
|
424
|
+
const { validateMcpAppsPackage } = await import("./mcp-apps-packaging-CixRnxz_.js");
|
|
425
|
+
const result = validateMcpAppsPackage(root);
|
|
426
|
+
for (const finding of result.findings) context.io.stderr(`${finding.code}: ${finding.message}\n`);
|
|
427
|
+
if (!result.valid) {
|
|
428
|
+
context.io.stderr(`Error: MCP Apps package validation failed with ${result.findings.length} finding(s)\n`);
|
|
429
|
+
return 1;
|
|
430
|
+
}
|
|
431
|
+
context.io.stdout("MCP Apps package at ./mcp-apps is valid.\n");
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
434
|
+
//#endregion
|
|
435
|
+
//#region src/app/process-record.ts
|
|
436
|
+
/**
|
|
437
|
+
* Managed web-process record (`<state>/app.pid`).
|
|
438
|
+
*
|
|
439
|
+
* Ported from the template's `scripts/smrt-process.mjs`. A recorded pid is
|
|
440
|
+
* only trusted when the live process's command line proves it is the web
|
|
441
|
+
* launcher with this record's instance nonce, so a recycled pid is never
|
|
442
|
+
* signalled.
|
|
443
|
+
*/
|
|
444
|
+
/** File name of the web launcher; part of the process-identity proof. */
|
|
445
|
+
var WEB_LAUNCHER_NAME = "smrt-web.mjs";
|
|
446
|
+
function processCommand(pid) {
|
|
447
|
+
if (platform() === "win32") {
|
|
448
|
+
const result = spawnSync("powershell.exe", [
|
|
449
|
+
"-NoProfile",
|
|
450
|
+
"-NonInteractive",
|
|
451
|
+
"-Command",
|
|
452
|
+
`(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`
|
|
453
|
+
], {
|
|
454
|
+
encoding: "utf8",
|
|
455
|
+
windowsHide: true
|
|
456
|
+
});
|
|
457
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
458
|
+
}
|
|
459
|
+
const result = spawnSync("ps", [
|
|
460
|
+
"-p",
|
|
461
|
+
String(pid),
|
|
462
|
+
"-o",
|
|
463
|
+
"command="
|
|
464
|
+
], { encoding: "utf8" });
|
|
465
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
466
|
+
}
|
|
467
|
+
/** Write the private (mode 0600) process record. */
|
|
468
|
+
function writeProcessRecord(path, record) {
|
|
469
|
+
writeFileSync(path, `${JSON.stringify(record)}\n`, { mode: 384 });
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Send SIGTERM. Returns `false` when the process had already exited; any
|
|
473
|
+
* other failure (e.g. EPERM) is rethrown.
|
|
474
|
+
*/
|
|
475
|
+
function sendTerminationSignal(pid, killProcess = process.kill) {
|
|
476
|
+
try {
|
|
477
|
+
killProcess(pid, "SIGTERM");
|
|
478
|
+
return true;
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (errorCode(error) === "ESRCH") return false;
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** Split a process command line into argv-like tokens, dropping quotes. */
|
|
485
|
+
function commandTokens(command) {
|
|
486
|
+
return command.trim().split(/\s+/).map((token) => token.replace(/^"|"$/g, "")).filter(Boolean);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* True when `command` is exactly the web launcher started with this record's
|
|
490
|
+
* nonce: the final argument is `--smrt-instance=<instance>` and the argument
|
|
491
|
+
* before it is a path whose basename is `smrt-web.mjs` (the CLI's
|
|
492
|
+
* `bin/smrt-web.mjs`, or the template's `scripts/smrt-web.mjs`). Substring
|
|
493
|
+
* matches such as `evil-smrt-web.mjs` or a nonce with a suffix are rejected.
|
|
494
|
+
*/
|
|
495
|
+
function matchesApplicationProcess(record, command) {
|
|
496
|
+
if (typeof command !== "string") return false;
|
|
497
|
+
const tokens = commandTokens(command);
|
|
498
|
+
if (tokens.length < 2) return false;
|
|
499
|
+
const script = tokens[tokens.length - 2];
|
|
500
|
+
return tokens[tokens.length - 1] === `--smrt-instance=${record.instance}` && script.split(/[\\/]/).pop() === "smrt-web.mjs";
|
|
501
|
+
}
|
|
502
|
+
function isLive(pid) {
|
|
503
|
+
try {
|
|
504
|
+
process.kill(pid, 0);
|
|
505
|
+
return true;
|
|
506
|
+
} catch (error) {
|
|
507
|
+
const code = errorCode(error);
|
|
508
|
+
return code === "EPERM" || code === "EACCES";
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Re-check `record` against the live process table. Only `gone` and
|
|
513
|
+
* `mismatched` justify dropping the record; `unverifiable` (the process
|
|
514
|
+
* query failed or returned nothing) must keep it and must not signal.
|
|
515
|
+
*/
|
|
516
|
+
function checkOwnedProcess(record) {
|
|
517
|
+
if (!isLive(record.pid)) return "gone";
|
|
518
|
+
const command = processCommand(record.pid);
|
|
519
|
+
if (command === null || command === "") return isLive(record.pid) ? "unverifiable" : "gone";
|
|
520
|
+
return matchesApplicationProcess(record, command) ? "owned" : "mismatched";
|
|
521
|
+
}
|
|
522
|
+
/** True only when {@link checkOwnedProcess} proves the record still owned. */
|
|
523
|
+
function verifyOwnedProcess(record) {
|
|
524
|
+
return checkOwnedProcess(record) === "owned";
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Return the recorded web process when it is live and provably ours;
|
|
528
|
+
* otherwise remove the stale record and return `null`.
|
|
529
|
+
*/
|
|
530
|
+
function readOwnedProcess(path) {
|
|
531
|
+
let record;
|
|
532
|
+
try {
|
|
533
|
+
record = JSON.parse(readFileSync(path, "utf8"));
|
|
534
|
+
if (!Number.isSafeInteger(record.pid) || record.pid < 1 || typeof record.instance !== "string" || !/^[a-f0-9]{32}$/.test(record.instance)) throw new Error("Invalid process record.");
|
|
535
|
+
} catch {
|
|
536
|
+
rmSync(path, { force: true });
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
process.kill(record.pid, 0);
|
|
541
|
+
} catch (error) {
|
|
542
|
+
const code = errorCode(error);
|
|
543
|
+
if (code !== "EPERM" && code !== "EACCES") {
|
|
544
|
+
rmSync(path, { force: true });
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const command = processCommand(record.pid);
|
|
549
|
+
if (command === null || command === "") throw new Error(`Application process ${record.pid} is live but its identity cannot be verified.`);
|
|
550
|
+
if (!matchesApplicationProcess(record, command)) {
|
|
551
|
+
rmSync(path, { force: true });
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
return record;
|
|
555
|
+
}
|
|
556
|
+
//#endregion
|
|
557
|
+
//#region src/app/operations.ts
|
|
558
|
+
/**
|
|
559
|
+
* Local/deployed application operations: install, setup, recover, start,
|
|
560
|
+
* stop, doctor, open, backup, export, import.
|
|
561
|
+
*
|
|
562
|
+
* Ported from the template's `scripts/smrt-app.mjs`. Output JSON, exit
|
|
563
|
+
* codes, state files, and environment variables are unchanged so the
|
|
564
|
+
* template's `pnpm app:*` scripts can become `smrt app <operation>`.
|
|
565
|
+
*/
|
|
566
|
+
/** Operations implemented by this module. */
|
|
567
|
+
var APP_OPERATIONS = [
|
|
568
|
+
"install",
|
|
569
|
+
"setup",
|
|
570
|
+
"recover",
|
|
571
|
+
"start",
|
|
572
|
+
"stop",
|
|
573
|
+
"doctor",
|
|
574
|
+
"open",
|
|
575
|
+
"backup",
|
|
576
|
+
"export",
|
|
577
|
+
"import"
|
|
578
|
+
];
|
|
579
|
+
function printJson(context, value, pretty = false) {
|
|
580
|
+
context.io.stdout(`${pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)}\n`);
|
|
581
|
+
}
|
|
582
|
+
function ensurePrivateDirectory(path) {
|
|
583
|
+
mkdirSync(path, {
|
|
584
|
+
recursive: true,
|
|
585
|
+
mode: 448
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
function nearestExistingAncestor(path) {
|
|
589
|
+
let candidate = path;
|
|
590
|
+
while (!existsSync(candidate)) {
|
|
591
|
+
const parent = dirname(candidate);
|
|
592
|
+
if (parent === candidate) return candidate;
|
|
593
|
+
candidate = parent;
|
|
594
|
+
}
|
|
595
|
+
return candidate;
|
|
596
|
+
}
|
|
597
|
+
function pidPath(context) {
|
|
598
|
+
return join(preparedStateRoot(context), "app.pid");
|
|
599
|
+
}
|
|
600
|
+
function onboardingPath(context) {
|
|
601
|
+
return join(preparedStateRoot(context), "onboarding.json");
|
|
602
|
+
}
|
|
603
|
+
function onboardingLaunchPath(context) {
|
|
604
|
+
return join(preparedStateRoot(context), "onboarding-launch.html");
|
|
605
|
+
}
|
|
606
|
+
function removeOnboardingHandoff(context) {
|
|
607
|
+
rmSync(onboardingPath(context), { force: true });
|
|
608
|
+
rmSync(onboardingLaunchPath(context), { force: true });
|
|
609
|
+
}
|
|
610
|
+
function writePrivateAtomic(destination, contents) {
|
|
611
|
+
const temporary = `${destination}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
612
|
+
try {
|
|
613
|
+
writeFileSync(temporary, contents, {
|
|
614
|
+
flag: "wx",
|
|
615
|
+
mode: 384
|
|
616
|
+
});
|
|
617
|
+
renameSync(temporary, destination);
|
|
618
|
+
} finally {
|
|
619
|
+
rmSync(temporary, { force: true });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function saveOnboardingLaunch(context, url) {
|
|
623
|
+
const escapedUrl = url.replaceAll("&", "&").replaceAll("\"", """);
|
|
624
|
+
writePrivateAtomic(onboardingLaunchPath(context), `<!doctype html><meta http-equiv="refresh" content="0;url=${escapedUrl}">\n`);
|
|
625
|
+
}
|
|
626
|
+
function saveOnboardingUrl(context, url) {
|
|
627
|
+
ensurePrivateDirectory(preparedStateRoot(context));
|
|
628
|
+
saveOnboardingLaunch(context, url);
|
|
629
|
+
writePrivateAtomic(onboardingPath(context), `${JSON.stringify({
|
|
630
|
+
schemaVersion: 1,
|
|
631
|
+
url
|
|
632
|
+
})}\n`);
|
|
633
|
+
}
|
|
634
|
+
var InvalidOnboardingHandoff = class extends Error {};
|
|
635
|
+
/** Read the retained loopback onboarding URL; tampered handoffs are removed. */
|
|
636
|
+
function readOnboardingUrl(context) {
|
|
637
|
+
try {
|
|
638
|
+
const value = JSON.parse(readFileSync(onboardingPath(context), "utf8"));
|
|
639
|
+
if (value?.schemaVersion !== 1 || typeof value.url !== "string") throw new InvalidOnboardingHandoff("Invalid onboarding handoff.");
|
|
640
|
+
const url = new URL(value.url);
|
|
641
|
+
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" && url.hostname !== "localhost" || url.pathname !== "/setup") throw new InvalidOnboardingHandoff("Invalid onboarding handoff.");
|
|
642
|
+
return url.toString();
|
|
643
|
+
} catch (error) {
|
|
644
|
+
if (errorCode(error) === "ENOENT" || error instanceof SyntaxError || error instanceof InvalidOnboardingHandoff || errorCode(error) === "ERR_INVALID_URL") {
|
|
645
|
+
removeOnboardingHandoff(context);
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/** Throw unless the configured profile is `local`. */
|
|
652
|
+
async function assertLocalOperation(context, operation) {
|
|
653
|
+
const runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
654
|
+
if (runtime.profile !== "local") throw new Error(`${operation} is local-profile only; run the production Node build or container for deployed profiles.`);
|
|
655
|
+
return runtime;
|
|
656
|
+
}
|
|
657
|
+
async function initializeLocal(context, runtime, env, options = {}) {
|
|
658
|
+
if (runtime.profile !== "local") return null;
|
|
659
|
+
return context.deps.runtime.initializeLocalApplicationRuntime({
|
|
660
|
+
appId: context.appId,
|
|
661
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
662
|
+
sourceRoot: context.sourceRoot,
|
|
663
|
+
bindHost: env.HOST || "127.0.0.1",
|
|
664
|
+
providers: {
|
|
665
|
+
database: runtime.providers.database,
|
|
666
|
+
authentication: runtime.providers.authentication,
|
|
667
|
+
tenancy: runtime.providers.tenancy,
|
|
668
|
+
assets: runtime.providers.assets,
|
|
669
|
+
secrets: runtime.providers.secrets,
|
|
670
|
+
jobs: runtime.providers.jobs,
|
|
671
|
+
network: runtime.providers.network
|
|
672
|
+
},
|
|
673
|
+
prepareDatabase: options.prepareDatabase ? async () => {
|
|
674
|
+
context.deps.runSmrt(["db:migrate"], {
|
|
675
|
+
env,
|
|
676
|
+
label: "smrt db:migrate"
|
|
677
|
+
});
|
|
678
|
+
} : void 0,
|
|
679
|
+
backgroundJobs: process.env.SMRT_BACKGROUND_JOBS === "true"
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
async function setup(context, operationLock) {
|
|
683
|
+
const runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
684
|
+
const operatorLease = runtime.profile === "local" ? acquireWriterLease(preparedStateRoot(context), { operationInstance: operationLock?.instance }) : null;
|
|
685
|
+
try {
|
|
686
|
+
const { env } = runtimeEnvironment(context, runtime);
|
|
687
|
+
context.deps.runPackageManager(["build"], {
|
|
688
|
+
env,
|
|
689
|
+
label: "pnpm build"
|
|
690
|
+
});
|
|
691
|
+
let initialized = null;
|
|
692
|
+
if (runtime.profile === "local") initialized = await initializeLocal(context, runtime, env, { prepareDatabase: true });
|
|
693
|
+
else context.deps.runSmrt(["db:migrate"], {
|
|
694
|
+
env,
|
|
695
|
+
label: "smrt db:migrate"
|
|
696
|
+
});
|
|
697
|
+
const port = env.PORT || "5173";
|
|
698
|
+
const onboardingUrl = initialized?.bootstrap ? `http://127.0.0.1:${port}/setup?token=${encodeURIComponent(initialized.bootstrap.token)}` : null;
|
|
699
|
+
const diagnostics = await initialized?.runtime.diagnostics();
|
|
700
|
+
if (onboardingUrl) saveOnboardingUrl(context, onboardingUrl);
|
|
701
|
+
if (diagnostics?.bootstrap.status === "claimed") removeOnboardingHandoff(context);
|
|
702
|
+
await initialized?.runtime.db.close?.();
|
|
703
|
+
const retainedOnboardingUrl = onboardingUrl || readOnboardingUrl(context);
|
|
704
|
+
const onboardingAvailable = runtime.profile === "local" && retainedOnboardingUrl !== null;
|
|
705
|
+
const report = {
|
|
706
|
+
schemaVersion: 1,
|
|
707
|
+
status: "ready",
|
|
708
|
+
profile: runtime.profile,
|
|
709
|
+
onboardingAvailable,
|
|
710
|
+
onboardingRecovery: onboardingAvailable ? "pnpm app:open" : null,
|
|
711
|
+
secretValuesIncluded: false
|
|
712
|
+
};
|
|
713
|
+
printJson(context, report, true);
|
|
714
|
+
return {
|
|
715
|
+
...report,
|
|
716
|
+
onboardingUrl: retainedOnboardingUrl
|
|
717
|
+
};
|
|
718
|
+
} finally {
|
|
719
|
+
operatorLease?.release();
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
async function recoverOnboarding(context, operationLock) {
|
|
723
|
+
const runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
724
|
+
const { env } = runtimeEnvironment(context, runtime);
|
|
725
|
+
if (runtime.profile !== "local") throw new Error("Owner onboarding recovery is local-only.");
|
|
726
|
+
const operatorLease = acquireWriterLease(preparedStateRoot(context), { operationInstance: operationLock?.instance });
|
|
727
|
+
try {
|
|
728
|
+
const initialized = await initializeLocal(context, runtime, env);
|
|
729
|
+
if (!initialized) throw new Error("Local runtime initialization failed.");
|
|
730
|
+
const invitation = await initialized.runtime.rotateBootstrapInvitation();
|
|
731
|
+
saveOnboardingUrl(context, `http://127.0.0.1:${env.PORT || "5173"}/setup?token=${encodeURIComponent(invitation.token)}`);
|
|
732
|
+
await initialized.runtime.db.close?.();
|
|
733
|
+
printJson(context, {
|
|
734
|
+
schemaVersion: 1,
|
|
735
|
+
status: "ready",
|
|
736
|
+
onboardingAvailable: true,
|
|
737
|
+
recovery: "Run pnpm app:start, then pnpm app:open.",
|
|
738
|
+
secretValuesIncluded: false
|
|
739
|
+
});
|
|
740
|
+
} finally {
|
|
741
|
+
operatorLease.release();
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function isAlive(pid) {
|
|
745
|
+
process.kill(pid, 0);
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
async function waitForReady(context, url, pid, instance, configuration) {
|
|
749
|
+
const healthUrl = new URL("/api/_runtime/health", url);
|
|
750
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
751
|
+
try {
|
|
752
|
+
const response = await context.deps.fetch(healthUrl, { redirect: "manual" });
|
|
753
|
+
if (response.status === 200) {
|
|
754
|
+
const health = await response.json();
|
|
755
|
+
if (health?.status === "ready" && health.application === context.appId && health.instance === instance && health.configuration === configuration) {
|
|
756
|
+
isAlive(pid);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
isAlive(pid);
|
|
761
|
+
} catch {
|
|
762
|
+
try {
|
|
763
|
+
isAlive(pid);
|
|
764
|
+
} catch {
|
|
765
|
+
throw new Error("The application process exited before becoming ready.");
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
await context.deps.sleep(250);
|
|
769
|
+
}
|
|
770
|
+
throw new Error(`The application did not become ready at ${url}.`);
|
|
771
|
+
}
|
|
772
|
+
async function start(context, operationLock) {
|
|
773
|
+
const runtime = await assertLocalOperation(context, "app:start");
|
|
774
|
+
const { env } = runtimeEnvironment(context, runtime);
|
|
775
|
+
const url = `http://127.0.0.1:${env.PORT || "5173"}/`;
|
|
776
|
+
env.ORIGIN ||= url;
|
|
777
|
+
const configuration = runtimeConfigurationFingerprint(runtime, env);
|
|
778
|
+
const existing = readOwnedProcess(pidPath(context));
|
|
779
|
+
if (existing) {
|
|
780
|
+
await waitForReady(context, url, existing.pid, existing.instance, configuration);
|
|
781
|
+
printJson(context, {
|
|
782
|
+
schemaVersion: 1,
|
|
783
|
+
status: "running",
|
|
784
|
+
pid: existing.pid
|
|
785
|
+
});
|
|
786
|
+
return existing.pid;
|
|
787
|
+
}
|
|
788
|
+
ensurePrivateDirectory(preparedStateRoot(context));
|
|
789
|
+
const instance = randomBytes(16).toString("hex");
|
|
790
|
+
const child = spawn(process.execPath, [webLauncherPath(), `--smrt-instance=${instance}`], {
|
|
791
|
+
cwd: context.sourceRoot,
|
|
792
|
+
env: {
|
|
793
|
+
...env,
|
|
794
|
+
HOST: runtime.profile === "local" ? "127.0.0.1" : env.HOST || "0.0.0.0",
|
|
795
|
+
PORT: env.PORT || "5173",
|
|
796
|
+
SMRT_PROCESS_INSTANCE: instance,
|
|
797
|
+
SMRT_OPERATION_INSTANCE: operationLock?.instance
|
|
798
|
+
},
|
|
799
|
+
detached: true,
|
|
800
|
+
stdio: "ignore"
|
|
801
|
+
});
|
|
802
|
+
child.unref();
|
|
803
|
+
const pid = child.pid;
|
|
804
|
+
if (pid === void 0) throw new Error("The application process could not be started.");
|
|
805
|
+
writeProcessRecord(pidPath(context), {
|
|
806
|
+
pid,
|
|
807
|
+
instance
|
|
808
|
+
});
|
|
809
|
+
try {
|
|
810
|
+
await waitForReady(context, url, pid, instance, configuration);
|
|
811
|
+
} catch (error) {
|
|
812
|
+
if (!await terminateFailedStart(context, child)) throw new Error(`${error instanceof Error ? error.message : "The application did not become ready."} Application process ${pid} did not exit after SIGTERM and SIGKILL and remains recorded; run pnpm app:stop.`);
|
|
813
|
+
rmSync(pidPath(context), { force: true });
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
printJson(context, {
|
|
817
|
+
schemaVersion: 1,
|
|
818
|
+
status: "started",
|
|
819
|
+
pid
|
|
820
|
+
});
|
|
821
|
+
return pid;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Terminate a launcher that failed to prove readiness: SIGTERM, wait, then
|
|
825
|
+
* SIGKILL, wait. Resolves `true` only once the child has provably exited.
|
|
826
|
+
* Uses the child handle we own, so a reaped pid is never signalled.
|
|
827
|
+
*/
|
|
828
|
+
async function terminateFailedStart(context, child) {
|
|
829
|
+
const exited = () => child.exitCode !== null || child.signalCode !== null;
|
|
830
|
+
for (const [signal, attempts] of [["SIGTERM", 50], ["SIGKILL", 20]]) {
|
|
831
|
+
if (exited()) return true;
|
|
832
|
+
try {
|
|
833
|
+
context.deps.signal(child.pid, signal);
|
|
834
|
+
} catch (error) {
|
|
835
|
+
if (errorCode(error) === "ESRCH") return true;
|
|
836
|
+
throw error;
|
|
837
|
+
}
|
|
838
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
839
|
+
await context.deps.sleep(100);
|
|
840
|
+
if (exited()) return true;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return exited();
|
|
844
|
+
}
|
|
845
|
+
async function stop(context) {
|
|
846
|
+
const record = readOwnedProcess(pidPath(context));
|
|
847
|
+
const pid = record?.pid || null;
|
|
848
|
+
if (!record || !pid) {
|
|
849
|
+
rmSync(pidPath(context), { force: true });
|
|
850
|
+
printJson(context, {
|
|
851
|
+
schemaVersion: 1,
|
|
852
|
+
status: "stopped"
|
|
853
|
+
});
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const state = checkOwnedProcess(record);
|
|
857
|
+
if (state === "unverifiable") throw new Error(`Application process ${pid} is live but its identity could not be re-verified; app.pid was kept and no signal was sent. Retry pnpm app:stop.`);
|
|
858
|
+
if (state !== "owned" || !sendTerminationSignal(pid)) {
|
|
859
|
+
rmSync(pidPath(context), { force: true });
|
|
860
|
+
printJson(context, {
|
|
861
|
+
schemaVersion: 1,
|
|
862
|
+
status: "stopped",
|
|
863
|
+
pid
|
|
864
|
+
});
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
868
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
|
|
869
|
+
try {
|
|
870
|
+
process.kill(pid, 0);
|
|
871
|
+
} catch {
|
|
872
|
+
break;
|
|
873
|
+
}
|
|
874
|
+
if (attempt === 39) throw new Error(`Application process ${pid} did not stop cleanly.`);
|
|
875
|
+
}
|
|
876
|
+
rmSync(pidPath(context), { force: true });
|
|
877
|
+
printJson(context, {
|
|
878
|
+
schemaVersion: 1,
|
|
879
|
+
status: "stopped",
|
|
880
|
+
pid
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
async function open(context) {
|
|
884
|
+
const runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
885
|
+
const url = `http://${runtime.profile === "local" ? "127.0.0.1" : process.env.HOST || "127.0.0.1"}:${process.env.PORT || "5173"}/`;
|
|
886
|
+
const onboardingUrl = runtime.profile === "local" ? readOnboardingUrl(context) : null;
|
|
887
|
+
if (onboardingUrl) saveOnboardingLaunch(context, onboardingUrl);
|
|
888
|
+
const destination = onboardingUrl ? pathToFileURL(onboardingLaunchPath(context)).toString() : url;
|
|
889
|
+
context.deps.openBrowser(destination);
|
|
890
|
+
printJson(context, {
|
|
891
|
+
schemaVersion: 1,
|
|
892
|
+
status: "opened",
|
|
893
|
+
url
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
async function doctor(context) {
|
|
897
|
+
const findings = [];
|
|
898
|
+
let runtime = null;
|
|
899
|
+
let paths = null;
|
|
900
|
+
let statePath = null;
|
|
901
|
+
let canInspectMigrations = false;
|
|
902
|
+
try {
|
|
903
|
+
runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
904
|
+
} catch {
|
|
905
|
+
findings.push({
|
|
906
|
+
code: "invalid-runtime-profile",
|
|
907
|
+
severity: "error",
|
|
908
|
+
message: "The canonical runtime profile is invalid.",
|
|
909
|
+
recovery: "Select local, self-hosted, or cloud in smrt.config.ts."
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
if (Number(process.versions.node.split(".")[0]) < 26) findings.push({
|
|
913
|
+
code: "unsupported-node",
|
|
914
|
+
severity: "error",
|
|
915
|
+
message: "Node.js 26 or newer is required.",
|
|
916
|
+
recovery: "Install the Node.js version declared in package.json engines."
|
|
917
|
+
});
|
|
918
|
+
if (runtime) {
|
|
919
|
+
if (runtime.profile !== "local") for (const [component, provider] of [
|
|
920
|
+
["authentication", runtime.providers.authentication.provider],
|
|
921
|
+
["assets", runtime.providers.assets.provider],
|
|
922
|
+
["secrets", runtime.providers.secrets.provider]
|
|
923
|
+
]) try {
|
|
924
|
+
await createProviderReadinessProbe(component, {
|
|
925
|
+
profile: runtime.profile,
|
|
926
|
+
provider
|
|
927
|
+
}, { sourceRoot: context.sourceRoot })();
|
|
928
|
+
} catch {
|
|
929
|
+
findings.push({
|
|
930
|
+
code: "provider-not-configured",
|
|
931
|
+
component,
|
|
932
|
+
severity: "error",
|
|
933
|
+
message: `The ${component} provider is not ready.`,
|
|
934
|
+
recovery: `Configure and verify the installed ${component} provider readiness module.`
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
try {
|
|
938
|
+
({paths} = runtimeEnvironment(context, runtime));
|
|
939
|
+
statePath = resolveApplicationStateRoot({
|
|
940
|
+
appId: context.appId,
|
|
941
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
942
|
+
sourceRoot: context.sourceRoot
|
|
943
|
+
});
|
|
944
|
+
accessSync(nearestExistingAncestor(statePath), constants.W_OK);
|
|
945
|
+
if (runtime.profile === "local") paths = await context.deps.runtime.validateLocalDatabaseStorage({
|
|
946
|
+
appId: context.appId,
|
|
947
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
948
|
+
sourceRoot: context.sourceRoot
|
|
949
|
+
});
|
|
950
|
+
canInspectMigrations = true;
|
|
951
|
+
const host = process.env.HOST || (runtime.profile === "local" ? "127.0.0.1" : "0.0.0.0");
|
|
952
|
+
if (runtime.profile === "local" && host !== "127.0.0.1" && host !== "::1") findings.push({
|
|
953
|
+
code: "unsafe-local-bind",
|
|
954
|
+
severity: "error",
|
|
955
|
+
message: "Local owner bootstrap may only bind to a loopback address.",
|
|
956
|
+
recovery: "Unset HOST or set HOST=127.0.0.1."
|
|
957
|
+
});
|
|
958
|
+
if (paths?.root) accessSync(nearestExistingAncestor(paths.root), constants.W_OK);
|
|
959
|
+
} catch (error) {
|
|
960
|
+
findings.push({
|
|
961
|
+
code: "runtime-path-unavailable",
|
|
962
|
+
severity: "error",
|
|
963
|
+
message: "A required runtime path or provider configuration is unavailable.",
|
|
964
|
+
recovery: error instanceof Error ? error.message : "Configure a writable runtime data path."
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
if (canInspectMigrations) try {
|
|
968
|
+
const status = context.deps.runSmrt(["db:status", "--json"], {
|
|
969
|
+
env: runtimeEnvironment(context, runtime).env,
|
|
970
|
+
capture: true,
|
|
971
|
+
allowFailure: true,
|
|
972
|
+
label: "smrt db:status --json"
|
|
973
|
+
});
|
|
974
|
+
if (status.status !== 0) findings.push({
|
|
975
|
+
code: "migration-status-failed",
|
|
976
|
+
severity: "error",
|
|
977
|
+
message: "Database migration status could not be verified.",
|
|
978
|
+
recovery: "Run pnpm app:setup and inspect the private migration logs."
|
|
979
|
+
});
|
|
980
|
+
else {
|
|
981
|
+
const migrationStatus = JSON.parse(status.stdout || "{}");
|
|
982
|
+
if ((migrationStatus.drift?.length || 0) > 0 || (migrationStatus.migrations?.failed?.actionRequired || 0) > 0 || migrationStatus.schemaContract?.ok === false || migrationStatus.preconditions?.some((item) => item.status === "error")) findings.push({
|
|
983
|
+
code: "migration-required",
|
|
984
|
+
severity: "error",
|
|
985
|
+
message: "Database migrations are pending or failed.",
|
|
986
|
+
recovery: "Run pnpm app:setup, then rerun pnpm app:doctor."
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
} catch {
|
|
990
|
+
findings.push({
|
|
991
|
+
code: "migration-status-failed",
|
|
992
|
+
severity: "error",
|
|
993
|
+
message: "Database migration status could not be parsed.",
|
|
994
|
+
recovery: "Run pnpm app:setup and inspect the private migration logs."
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
const report = {
|
|
999
|
+
schemaVersion: 1,
|
|
1000
|
+
status: findings.some((finding) => finding.severity === "error") ? "error" : "ready",
|
|
1001
|
+
profile: runtime?.profile || null,
|
|
1002
|
+
capabilities: runtime?.capabilities || null,
|
|
1003
|
+
paths: paths || statePath ? {
|
|
1004
|
+
root: paths?.root || null,
|
|
1005
|
+
database: paths?.database || null,
|
|
1006
|
+
assets: paths?.assets || null,
|
|
1007
|
+
state: statePath
|
|
1008
|
+
} : null,
|
|
1009
|
+
findings,
|
|
1010
|
+
secretValuesIncluded: false
|
|
1011
|
+
};
|
|
1012
|
+
printJson(context, report, true);
|
|
1013
|
+
return report.status === "error" ? 1 : 0;
|
|
1014
|
+
}
|
|
1015
|
+
async function backup(context, args, operationLock) {
|
|
1016
|
+
if ((await context.deps.resolveRuntime(context.sourceRoot)).profile !== "local") throw new Error("This scaffold delegates deployed backups to the selected operator or managed provider.");
|
|
1017
|
+
const explicitDestination = args[0] ? assertExternalArtifactPath({
|
|
1018
|
+
sourceRoot: context.sourceRoot,
|
|
1019
|
+
path: resolve(args[0]),
|
|
1020
|
+
label: "Backup destination"
|
|
1021
|
+
}) : null;
|
|
1022
|
+
const paths = await context.deps.runtime.validateLocalDatabaseStorage({
|
|
1023
|
+
appId: context.appId,
|
|
1024
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
1025
|
+
sourceRoot: context.sourceRoot
|
|
1026
|
+
});
|
|
1027
|
+
const operatorLease = acquireWriterLease(preparedStateRoot(context), { operationInstance: operationLock?.instance });
|
|
1028
|
+
try {
|
|
1029
|
+
const destination = assertExternalArtifactPath({
|
|
1030
|
+
sourceRoot: context.sourceRoot,
|
|
1031
|
+
path: explicitDestination || join(dirname(paths.root), "backups", `${context.appId}-${(/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-")}`),
|
|
1032
|
+
label: "Backup destination"
|
|
1033
|
+
});
|
|
1034
|
+
ensurePrivateDirectory(dirname(destination));
|
|
1035
|
+
try {
|
|
1036
|
+
mkdirSync(destination, { mode: 448 });
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
if (errorCode(error) === "EEXIST") throw new Error(`Backup destination already exists: ${destination}`);
|
|
1039
|
+
throw error;
|
|
1040
|
+
}
|
|
1041
|
+
try {
|
|
1042
|
+
cpSync(paths.root, destination, {
|
|
1043
|
+
recursive: true,
|
|
1044
|
+
force: false,
|
|
1045
|
+
errorOnExist: true
|
|
1046
|
+
});
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
rmSync(destination, {
|
|
1049
|
+
recursive: true,
|
|
1050
|
+
force: true
|
|
1051
|
+
});
|
|
1052
|
+
throw error;
|
|
1053
|
+
}
|
|
1054
|
+
printJson(context, {
|
|
1055
|
+
schemaVersion: 1,
|
|
1056
|
+
status: "backed-up",
|
|
1057
|
+
destination
|
|
1058
|
+
});
|
|
1059
|
+
} finally {
|
|
1060
|
+
operatorLease.release();
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* The application's own adapter at `scripts/smrt-portability.mjs` keeps
|
|
1065
|
+
* working as the documented extension point; otherwise the built-in adapter
|
|
1066
|
+
* is used.
|
|
1067
|
+
*/
|
|
1068
|
+
async function loadPortabilityAdapter(context) {
|
|
1069
|
+
const override = join(context.sourceRoot, "scripts", "smrt-portability.mjs");
|
|
1070
|
+
if (existsSync(override)) return await import(pathToFileURL(override).href);
|
|
1071
|
+
return import("./portability-DmQWSSXA.js").then((n) => n.s);
|
|
1072
|
+
}
|
|
1073
|
+
async function portability(context, operation, args, operationLock) {
|
|
1074
|
+
const adapter = await loadPortabilityAdapter(context);
|
|
1075
|
+
const runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
1076
|
+
const environment = runtimeEnvironment(context, runtime);
|
|
1077
|
+
const requestedPath = args[0] ? resolve(args[0]) : void 0;
|
|
1078
|
+
const artifactPath = requestedPath ? assertExternalArtifactPath({
|
|
1079
|
+
sourceRoot: context.sourceRoot,
|
|
1080
|
+
path: requestedPath,
|
|
1081
|
+
label: operation === "export" ? "Export destination" : "Import source"
|
|
1082
|
+
}) : requestedPath;
|
|
1083
|
+
if (runtime.profile === "local") environment.paths = await context.deps.runtime.validateLocalDatabaseStorage({
|
|
1084
|
+
appId: context.appId,
|
|
1085
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
1086
|
+
sourceRoot: context.sourceRoot
|
|
1087
|
+
});
|
|
1088
|
+
const portabilityContext = {
|
|
1089
|
+
appId: context.appId,
|
|
1090
|
+
sourceRoot: context.sourceRoot,
|
|
1091
|
+
stateRoot: preparedStateRoot(context),
|
|
1092
|
+
runtime,
|
|
1093
|
+
env: environment.env,
|
|
1094
|
+
paths: environment.paths,
|
|
1095
|
+
assetRoot: environment.assetRoot
|
|
1096
|
+
};
|
|
1097
|
+
let operatorLease = null;
|
|
1098
|
+
if (operation === "import") {
|
|
1099
|
+
if (runtime.profile === "local") operatorLease = acquireWriterLease(preparedStateRoot(context), { operationInstance: operationLock?.instance });
|
|
1100
|
+
if (runtime.profile !== "local" && process.env.SMRT_MAINTENANCE_MODE !== "true") throw new Error("Stop deployed web/workers and set SMRT_MAINTENANCE_MODE=true before importing.");
|
|
1101
|
+
}
|
|
1102
|
+
try {
|
|
1103
|
+
const result = await adapter[operation === "export" ? "exportApplication" : "importApplication"]({
|
|
1104
|
+
...portabilityContext,
|
|
1105
|
+
path: artifactPath
|
|
1106
|
+
});
|
|
1107
|
+
printJson(context, {
|
|
1108
|
+
schemaVersion: 1,
|
|
1109
|
+
status: `${operation}ed`,
|
|
1110
|
+
...result
|
|
1111
|
+
}, true);
|
|
1112
|
+
} finally {
|
|
1113
|
+
operatorLease?.release();
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Run one application operation. Resolves to the process exit code; throws
|
|
1118
|
+
* on failure (the caller renders the error envelope).
|
|
1119
|
+
*/
|
|
1120
|
+
async function runApplicationOperation(context, operation, args) {
|
|
1121
|
+
switch (operation) {
|
|
1122
|
+
case "install":
|
|
1123
|
+
await assertLocalOperation(context, "app:install");
|
|
1124
|
+
await withOperationLock(preparedStateRoot(context), "install", async (lock) => {
|
|
1125
|
+
const report = await setup(context, lock);
|
|
1126
|
+
await start(context, lock);
|
|
1127
|
+
const baseUrl = `http://127.0.0.1:${process.env.PORT || "5173"}/`;
|
|
1128
|
+
if (report.onboardingUrl) saveOnboardingLaunch(context, report.onboardingUrl);
|
|
1129
|
+
context.deps.openBrowser(report.onboardingUrl ? pathToFileURL(onboardingLaunchPath(context)).toString() : baseUrl);
|
|
1130
|
+
});
|
|
1131
|
+
return 0;
|
|
1132
|
+
case "setup":
|
|
1133
|
+
await withOperationLock(preparedStateRoot(context), operation, (lock) => setup(context, lock));
|
|
1134
|
+
return 0;
|
|
1135
|
+
case "recover":
|
|
1136
|
+
await assertLocalOperation(context, "app:recover");
|
|
1137
|
+
await withOperationLock(preparedStateRoot(context), operation, (lock) => recoverOnboarding(context, lock));
|
|
1138
|
+
return 0;
|
|
1139
|
+
case "start":
|
|
1140
|
+
await assertLocalOperation(context, "app:start");
|
|
1141
|
+
await withOperationLock(preparedStateRoot(context), operation, (lock) => start(context, lock));
|
|
1142
|
+
return 0;
|
|
1143
|
+
case "doctor": return doctor(context);
|
|
1144
|
+
case "open":
|
|
1145
|
+
await open(context);
|
|
1146
|
+
return 0;
|
|
1147
|
+
case "stop":
|
|
1148
|
+
await assertLocalOperation(context, "app:stop");
|
|
1149
|
+
await withOperationLock(preparedStateRoot(context), operation, () => stop(context));
|
|
1150
|
+
return 0;
|
|
1151
|
+
case "backup":
|
|
1152
|
+
await withOperationLock(preparedStateRoot(context), operation, (lock) => backup(context, args, lock));
|
|
1153
|
+
return 0;
|
|
1154
|
+
case "export":
|
|
1155
|
+
case "import":
|
|
1156
|
+
await withOperationLock(preparedStateRoot(context), operation, (lock) => portability(context, operation, args, lock));
|
|
1157
|
+
return 0;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
//#endregion
|
|
1161
|
+
//#region src/app/cli.ts
|
|
1162
|
+
/**
|
|
1163
|
+
* `smrt app <operation>` dispatcher.
|
|
1164
|
+
*
|
|
1165
|
+
* Every operation writes machine-readable JSON to stdout and, on failure,
|
|
1166
|
+
* one secret-free JSON envelope to stderr with exit code 1 — the contract
|
|
1167
|
+
* the template's `scripts/smrt-app.mjs` established.
|
|
1168
|
+
*/
|
|
1169
|
+
var cli_exports = /* @__PURE__ */ __exportAll({
|
|
1170
|
+
APP_COMMANDS: () => APP_COMMANDS,
|
|
1171
|
+
APP_RECOVERY: () => APP_RECOVERY,
|
|
1172
|
+
errorEnvelope: () => errorEnvelope,
|
|
1173
|
+
renderAppHelp: () => renderAppHelp,
|
|
1174
|
+
runAppCommand: () => runAppCommand
|
|
1175
|
+
});
|
|
1176
|
+
/** Recovery line carried by every error envelope. */
|
|
1177
|
+
var APP_RECOVERY = "Run pnpm app:doctor and follow its recovery instructions.";
|
|
1178
|
+
/** Every `smrt app` command with a one-line description (for help output). */
|
|
1179
|
+
var APP_COMMANDS = Object.freeze({
|
|
1180
|
+
install: "Local: set up, start, and open owner onboarding in one step",
|
|
1181
|
+
setup: "Build, apply migrations explicitly, and prepare owner onboarding",
|
|
1182
|
+
recover: "Local: issue a fresh single-use owner onboarding invitation",
|
|
1183
|
+
start: "Local: start the production build on loopback and prove readiness",
|
|
1184
|
+
stop: "Local: stop the managed application process",
|
|
1185
|
+
doctor: "Report runtime, storage, provider, and migration findings (default)",
|
|
1186
|
+
open: "Open the application or pending owner onboarding in a browser",
|
|
1187
|
+
"backup [destination]": "Local: copy the private data root to a new directory",
|
|
1188
|
+
"export [path]": "Write a logical, asset-aware export bundle",
|
|
1189
|
+
"import <path>": "Import a logical export bundle into an empty application",
|
|
1190
|
+
migrate: "Prepare storage custody and run smrt db:migrate under the lock",
|
|
1191
|
+
"worker [task|schedule]": "Deployed: run the task or schedule worker",
|
|
1192
|
+
"dev [...vite args]": "Run the application's Vite dev server",
|
|
1193
|
+
"build [...vite args]": "Validate ./mcp-apps when present, then run the Vite build",
|
|
1194
|
+
"vite [...args]": "Run the application's installed Vite CLI with args"
|
|
1195
|
+
});
|
|
1196
|
+
/** Build the stderr envelope for `error`, redacting secret material. */
|
|
1197
|
+
function errorEnvelope(error) {
|
|
1198
|
+
const runtimeCode = error instanceof Error && error.name === "LocalRuntimeError" && typeof error.code === "string" ? error.code : void 0;
|
|
1199
|
+
return {
|
|
1200
|
+
schemaVersion: 1,
|
|
1201
|
+
status: "error",
|
|
1202
|
+
code: "operation-failed",
|
|
1203
|
+
...runtimeCode ? { runtimeCode } : {},
|
|
1204
|
+
message: redactSecrets(error instanceof Error ? error.message : "Application operation failed."),
|
|
1205
|
+
recovery: APP_RECOVERY,
|
|
1206
|
+
secretValuesIncluded: false
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
/** Render `smrt app` usage. */
|
|
1210
|
+
function renderAppHelp() {
|
|
1211
|
+
const width = Math.max(...Object.keys(APP_COMMANDS).map((key) => key.length));
|
|
1212
|
+
return `Usage: smrt app <operation> [args]\n\nOperations:\n${Object.entries(APP_COMMANDS).map(([name, description]) => ` ${name.padEnd(width)} ${description}`).join("\n")}\n`;
|
|
1213
|
+
}
|
|
1214
|
+
var ENV_FILE_OPERATIONS = /* @__PURE__ */ new Set([
|
|
1215
|
+
...APP_OPERATIONS,
|
|
1216
|
+
"migrate",
|
|
1217
|
+
"dev",
|
|
1218
|
+
"build",
|
|
1219
|
+
"vite"
|
|
1220
|
+
]);
|
|
1221
|
+
/**
|
|
1222
|
+
* Run `smrt app <argv…>` and resolve to the process exit code.
|
|
1223
|
+
*
|
|
1224
|
+
* `argv` is everything after `app`. A leading `--` before operation
|
|
1225
|
+
* arguments is ignored, matching `pnpm app:import -- <path>`.
|
|
1226
|
+
*/
|
|
1227
|
+
async function runAppCommand(argv, options = {}) {
|
|
1228
|
+
const io = {
|
|
1229
|
+
stdout: options.io?.stdout ?? ((text) => void process.stdout.write(text)),
|
|
1230
|
+
stderr: options.io?.stderr ?? ((text) => void process.stderr.write(text))
|
|
1231
|
+
};
|
|
1232
|
+
const operation = argv[0] || "doctor";
|
|
1233
|
+
const rawArgs = argv.slice(1);
|
|
1234
|
+
const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
|
|
1235
|
+
if (operation === "help" || operation === "--help" || operation === "-h") {
|
|
1236
|
+
io.stdout(renderAppHelp());
|
|
1237
|
+
return 0;
|
|
1238
|
+
}
|
|
1239
|
+
try {
|
|
1240
|
+
const sourceRoot = resolve(options.cwd ?? process.cwd());
|
|
1241
|
+
if (ENV_FILE_OPERATIONS.has(operation)) loadSourceEnvironment(sourceRoot);
|
|
1242
|
+
if (operation === "dev" || operation === "build" || operation === "vite") {
|
|
1243
|
+
const context = {
|
|
1244
|
+
sourceRoot,
|
|
1245
|
+
appId: "",
|
|
1246
|
+
io,
|
|
1247
|
+
deps: {}
|
|
1248
|
+
};
|
|
1249
|
+
if (operation === "build") {
|
|
1250
|
+
const status = await validateMcpAppsIfPresent(context);
|
|
1251
|
+
if (status !== 0) return status;
|
|
1252
|
+
}
|
|
1253
|
+
return launchVite(context, operation === "vite" ? args : [operation, ...args]);
|
|
1254
|
+
}
|
|
1255
|
+
const deps = {
|
|
1256
|
+
...await defaultDependencies(sourceRoot),
|
|
1257
|
+
...options.dependencies
|
|
1258
|
+
};
|
|
1259
|
+
const context = {
|
|
1260
|
+
sourceRoot,
|
|
1261
|
+
appId: resolveContextApplicationId(sourceRoot),
|
|
1262
|
+
io,
|
|
1263
|
+
deps
|
|
1264
|
+
};
|
|
1265
|
+
if (operation === "migrate") return await prepareMigration(context);
|
|
1266
|
+
if (operation === "worker") return await runWorker(context, args);
|
|
1267
|
+
if (!APP_OPERATIONS.includes(operation)) throw new Error(`Unknown app operation: ${operation}`);
|
|
1268
|
+
return await runApplicationOperation(context, operation, args);
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
io.stderr(`${JSON.stringify(errorEnvelope(error))}\n`);
|
|
1271
|
+
return 1;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
//#endregion
|
|
1275
|
+
export { AppCommandError as A, runtimeEnvironment as C, resolveApplicationId as D, prepareApplicationStateRoot as E, redactSecrets as M, resolveApplicationStateRoot as O, cliExecutablePath as S, assertExternalArtifactPath as T, launchVite as _, renderAppHelp as a, runWorker as b, runApplicationOperation as c, matchesApplicationProcess as d, readOwnedProcess as f, ViteLauncherError as g, writeProcessRecord as h, errorEnvelope as i, errorCode as j, runtimeConfigurationFingerprint as k, WEB_LAUNCHER_NAME as l, verifyOwnedProcess as m, APP_RECOVERY as n, runAppCommand as o, sendTerminationSignal as p, cli_exports as r, APP_OPERATIONS as s, APP_COMMANDS as t, checkOwnedProcess as u, prepareMigration as v, webLauncherPath as w, validateMcpAppsIfPresent as x, resolveViteEntry as y };
|