@happyvertical/smrt-cli 0.53.3 → 0.53.5
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 +3 -0
- package/README.md +64 -0
- package/agents/app-commands.md +118 -0
- package/bin/smrt-web.mjs +19 -0
- package/bin/smrt.js +18 -12
- package/dist/{ApiKey-BZmbOTnL-DchyhIPA.js → ApiKey-BZmbOTnL-BbZupUZ3.js} +2 -2
- package/dist/ApiKeyCollection-Bfb_nw81-DYAZwx0O.js +87 -0
- package/dist/AuditLogCollection-B1YonCAP-Bkj2Ezon.js +193 -0
- package/dist/{NostrIdentity-n70E0vck-CLGPdJ1W.js → NostrIdentity-n70E0vck-Di3_eNCq.js} +4 -4
- package/dist/NostrIdentityCollection-Ca74dkaL-DvjOg9Xy.js +127 -0
- package/dist/{OidcIdentity-Cb1bxreM-DPBTZddF.js → OidcIdentity-Cb1bxreM-LXmC7uAf.js} +2 -2
- package/dist/OidcIdentityCollection-TV8VdK69-Cv1ZTeuk.js +86 -0
- package/dist/ProfileAssetCollection-B9hacUJa-CPuaWLk4.js +101 -0
- package/dist/{ProfileCollection-DuJT7Mum-BUYQQ9xZ.js → ProfileCollection-DuJT7Mum-nXbHrPpX.js} +31 -31
- package/dist/ProfileLinkCollection-4uNURz32-CbKZYmYm.js +233 -0
- package/dist/ProfileMetadataCollection-DQhbfEfa-_1rQCLTR.js +108 -0
- package/dist/ProfileMetafieldCollection-CoBuM_5G-CPjW3l5r.js +145 -0
- package/dist/ProfileRelationshipCollection-DUVgaIqf-RCDqYz6O.js +161 -0
- package/dist/ProfileRelationshipTermCollection-DtA9OM23-BoGhQBSQ.js +107 -0
- package/dist/{ProfileRelationshipType-BRiRka6t-xzRfZRFG.js → ProfileRelationshipType-BRiRka6t-Dezjv5jP.js} +1 -1
- package/dist/ProfileRelationshipTypeCollection-BdXwd7Kf-HoTmV1q-.js +57 -0
- package/dist/app/cli.d.ts +51 -0
- package/dist/app/cli.d.ts.map +1 -0
- package/dist/app/errors.d.ts +55 -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 +20 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/launchers.d.ts +51 -0
- package/dist/app/launchers.d.ts.map +1 -0
- package/dist/app/operations.d.ts +30 -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 +98 -0
- package/dist/app/runtime.d.ts.map +1 -0
- package/dist/app/tokens.d.ts +21 -0
- package/dist/app/tokens.d.ts.map +1 -0
- package/dist/app.d.ts +1 -0
- package/dist/app.js +4 -0
- package/dist/cli-OZsH5cdL.js +1602 -0
- package/dist/cli-generator.d.ts.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/utilities.d.ts.map +1 -1
- package/dist/{commands-B8N38deG.js → commands-UXK2F_0j.js} +49 -336
- package/dist/database-environment-D_Gk1Brf.js +20 -0
- package/dist/database-environment.d.ts +43 -0
- package/dist/database-environment.d.ts.map +1 -0
- package/dist/dist-BVjJWjT-.js +20 -0
- package/dist/{dist-B62O-xH6.js → dist-C87Ib9Vx.js} +2 -2
- package/dist/{dist-CL9KGm1j.js → dist-DLw_E34G.js} +1654 -1748
- package/dist/{dist-BBQyIRsO.js → dist-GIaWaUho.js} +4 -4
- package/dist/{dist-C8BOgli4.js → dist-RSE5hCY4.js} +3 -3
- package/dist/index.js +19 -9
- package/dist/mcp-apps-packaging-CixRnxz_.js +286 -0
- package/dist/{oidcProvisioningPrimitives-D9OYu5-F-l8afZs4F.js → oidcProvisioningPrimitives-D9OYu5-F-T9KkkDCd.js} +5 -5
- package/dist/portability-CES1YC2m.js +859 -0
- package/dist/resolveIdentity-B-PK6RwU-C9ogf8YG.js +269 -0
- package/package.json +19 -14
- package/dist/ApiKeyCollection-Bfb_nw81-DR5XF_SZ.js +0 -2
- package/dist/AuditLogCollection-B1YonCAP-PbzALncL.js +0 -2
- package/dist/NostrIdentityCollection-Ca74dkaL-C2r-uDlC.js +0 -2
- package/dist/OidcIdentityCollection-TV8VdK69-2fZROnih.js +0 -2
- package/dist/ProfileAssetCollection-B9hacUJa-D33sifgF.js +0 -2
- package/dist/ProfileLinkCollection-4uNURz32-CHDTinax.js +0 -2
- package/dist/ProfileMetadataCollection-DQhbfEfa-BQTogfVX.js +0 -2
- package/dist/ProfileMetafieldCollection-CoBuM_5G-BXtoIP02.js +0 -2
- package/dist/ProfileRelationshipCollection-DUVgaIqf-CNMliUF3.js +0 -2
- package/dist/ProfileRelationshipTermCollection-DtA9OM23-DPOMtMr7.js +0 -2
- package/dist/ProfileRelationshipTypeCollection-BdXwd7Kf-Co3PdH01.js +0 -2
- package/dist/resolveIdentity-B-PK6RwU-B306Ch1I.js +0 -2
|
@@ -0,0 +1,1602 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
|
|
2
|
+
import { accessSync, chmodSync, closeSync, constants, cpSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { acquireWriterLease, createProviderReadinessProbe, findInstalledPackage, openLocalMcpTokenStore, 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
|
+
/**
|
|
31
|
+
* A `smrt app start` whose web process never proved readiness. Carries the
|
|
32
|
+
* already-redacted, bounded tail of that process's output and the private
|
|
33
|
+
* log it came from, for the error envelope.
|
|
34
|
+
*/
|
|
35
|
+
var ApplicationStartError = class extends Error {
|
|
36
|
+
output;
|
|
37
|
+
logFile;
|
|
38
|
+
constructor(message, output, logFile) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "ApplicationStartError";
|
|
41
|
+
this.output = output;
|
|
42
|
+
this.logFile = logFile;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/** Replacement marker for every redacted value. */
|
|
46
|
+
var REDACTED = "[redacted]";
|
|
47
|
+
/** Minimum length before an environment value is treated as redactable. */
|
|
48
|
+
var MIN_REDACTED_VALUE_LENGTH = 8;
|
|
49
|
+
/** Environment names whose values must never reach operator output. */
|
|
50
|
+
var SECRET_ENVIRONMENT_NAME = /(?:^|_)(?:DATABASE_URL|URL|DSN|TOKEN|SECRET|PASSWORD|PASSWD|KEY|CREDENTIALS?|AUTH)(?:_|$)/i;
|
|
51
|
+
/** URL userinfo (`scheme://user:pass@`); group 2 is the credential span. */
|
|
52
|
+
var URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)([^\s/@:'"]*(?::[^\s/@'"]*)?)@/dgi;
|
|
53
|
+
/** Query/parameter credential (`?token=…`); group 2 is the value span. */
|
|
54
|
+
var QUERY_CREDENTIAL = /([?&;](?:token|password|secret|key|access_token)=)([^&\s"'<>]+)/dgi;
|
|
55
|
+
/** Bearer token; group 2 is the token span (8+ characters unless strict). */
|
|
56
|
+
var BEARER = /\b(Bearer\s+)([A-Za-z0-9._~+/=-]{8,})/dg;
|
|
57
|
+
var BEARER_ANY_LENGTH = /\b(Bearer\s+)([A-Za-z0-9._~+/=-]+)/dg;
|
|
58
|
+
function structuralSpans(text, strict) {
|
|
59
|
+
const spans = [];
|
|
60
|
+
for (const pattern of [
|
|
61
|
+
URL_USERINFO,
|
|
62
|
+
QUERY_CREDENTIAL,
|
|
63
|
+
strict ? BEARER_ANY_LENGTH : BEARER
|
|
64
|
+
]) for (const match of text.matchAll(pattern)) {
|
|
65
|
+
const span = match.indices?.[2];
|
|
66
|
+
if (span && (span[1] > span[0] || pattern === URL_USERINFO)) spans.push([span[0], span[1]]);
|
|
67
|
+
}
|
|
68
|
+
return spans;
|
|
69
|
+
}
|
|
70
|
+
/** Every existing `[redacted]` marker in `text`: opaque to later passes. */
|
|
71
|
+
function markerSpans(text) {
|
|
72
|
+
return literalSpans(text, [REDACTED], []);
|
|
73
|
+
}
|
|
74
|
+
function literalSpans(text, values, opaque) {
|
|
75
|
+
const spans = [];
|
|
76
|
+
for (const value of values) for (let index = text.indexOf(value); index !== -1; index = text.indexOf(value, index + 1)) {
|
|
77
|
+
const end = index + value.length;
|
|
78
|
+
if (opaque.some(([start, stop]) => index >= start && end <= stop)) continue;
|
|
79
|
+
spans.push([index, end]);
|
|
80
|
+
}
|
|
81
|
+
return spans;
|
|
82
|
+
}
|
|
83
|
+
/** Replace the union of `spans` (overlapping or touching spans merge). */
|
|
84
|
+
function maskSpans(text, spans) {
|
|
85
|
+
if (spans.length === 0) return text;
|
|
86
|
+
spans.sort((left, right) => left[0] - right[0] || right[1] - left[1]);
|
|
87
|
+
let result = "";
|
|
88
|
+
let cursor = 0;
|
|
89
|
+
let [start, end] = spans[0];
|
|
90
|
+
const flush = () => {
|
|
91
|
+
result += text.slice(cursor, start) + REDACTED;
|
|
92
|
+
cursor = end;
|
|
93
|
+
};
|
|
94
|
+
for (const [nextStart, nextEnd] of spans.slice(1)) {
|
|
95
|
+
if (nextStart <= end) {
|
|
96
|
+
end = Math.max(end, nextEnd);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
flush();
|
|
100
|
+
[start, end] = [nextStart, nextEnd];
|
|
101
|
+
}
|
|
102
|
+
flush();
|
|
103
|
+
return result + text.slice(cursor);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Remove secret material from an operator-facing message.
|
|
107
|
+
*
|
|
108
|
+
* Errors raised by drivers, config loaders, and providers routinely echo a
|
|
109
|
+
* connection string, credential, or bootstrap token. Redaction covers:
|
|
110
|
+
* literal values of secret-named environment variables, URL userinfo,
|
|
111
|
+
* `token=`/`password=`/`secret=` query values, and bearer tokens.
|
|
112
|
+
*
|
|
113
|
+
* Every span is located on the raw text and the union is masked at once, so
|
|
114
|
+
* a literal value can never split a structural match (an env value that is
|
|
115
|
+
* a Bearer-token prefix, or a URL's scheme and host, used to leave the rest
|
|
116
|
+
* of the token or the URL password visible). A final structural pass over
|
|
117
|
+
* the masked text keeps everything the earlier sequential order masked.
|
|
118
|
+
*/
|
|
119
|
+
function redactSecrets(message, environment = process.env, options = {}) {
|
|
120
|
+
const strict = options.strict === true;
|
|
121
|
+
const minimumLength = strict ? 1 : MIN_REDACTED_VALUE_LENGTH;
|
|
122
|
+
const secretValues = Object.entries(environment).filter(([name, value]) => typeof value === "string" && value.length >= minimumLength && SECRET_ENVIRONMENT_NAME.test(name)).map(([, value]) => value);
|
|
123
|
+
const markers = markerSpans(message);
|
|
124
|
+
const masked = maskSpans(message, [
|
|
125
|
+
...markers,
|
|
126
|
+
...structuralSpans(message, strict),
|
|
127
|
+
...literalSpans(message, secretValues, markers)
|
|
128
|
+
]);
|
|
129
|
+
return maskSpans(masked, [...markerSpans(masked), ...structuralSpans(masked, strict)]);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The last `maxBytes` of `text`: a leading partial line (and any split
|
|
133
|
+
* multi-byte character) is dropped when the cut lands mid-text. Redact
|
|
134
|
+
* before cutting; cutting never exposes anything redaction masked.
|
|
135
|
+
*/
|
|
136
|
+
function boundedTail(text, maxBytes) {
|
|
137
|
+
const bytes = Buffer.from(text, "utf8");
|
|
138
|
+
if (bytes.length <= maxBytes) return text;
|
|
139
|
+
const tail = bytes.subarray(bytes.length - maxBytes).toString("utf8");
|
|
140
|
+
let cut = tail.slice(tail.indexOf("\n") + 1);
|
|
141
|
+
while (Buffer.byteLength(cut) > maxBytes) cut = cut.slice(1);
|
|
142
|
+
return cut;
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/app/identity.ts
|
|
146
|
+
/**
|
|
147
|
+
* Application identity, private state root, and operator-artifact path
|
|
148
|
+
* custody for the `smrt app` command group.
|
|
149
|
+
*
|
|
150
|
+
* Identity, configuration fingerprint, and the private state root are
|
|
151
|
+
* `@happyvertical/smrt-app-runtime`'s single implementation (re-exported
|
|
152
|
+
* here), so the running web process and these commands can never derive
|
|
153
|
+
* different identities or lock domains. Operator-artifact path custody is
|
|
154
|
+
* CLI-only and stays here.
|
|
155
|
+
*/
|
|
156
|
+
function isInside(parent, child) {
|
|
157
|
+
const relative = child.slice(parent.length);
|
|
158
|
+
return child === parent || child.startsWith(parent) && (relative.startsWith("/") || relative.startsWith("\\"));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Resolve an operator-selected artifact through its nearest existing ancestor
|
|
162
|
+
* and reject paths that could place application data in or over the checkout.
|
|
163
|
+
*/
|
|
164
|
+
function assertExternalArtifactPath(options) {
|
|
165
|
+
const canonicalSource = realpathSync(resolve(options.sourceRoot));
|
|
166
|
+
const missingSegments = [];
|
|
167
|
+
let existingAncestor = resolve(options.path);
|
|
168
|
+
while (!existsSync(existingAncestor)) {
|
|
169
|
+
const parent = dirname(existingAncestor);
|
|
170
|
+
if (parent === existingAncestor) break;
|
|
171
|
+
missingSegments.unshift(basename(existingAncestor));
|
|
172
|
+
existingAncestor = parent;
|
|
173
|
+
}
|
|
174
|
+
const canonicalPath = resolve(realpathSync(existingAncestor), ...missingSegments);
|
|
175
|
+
if (isInside(canonicalSource, canonicalPath) || isInside(canonicalPath, canonicalSource)) throw new Error(`${options.label || "Artifact"} must remain outside the source tree.`);
|
|
176
|
+
return canonicalPath;
|
|
177
|
+
}
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/app/runtime.ts
|
|
180
|
+
/**
|
|
181
|
+
* Shared per-invocation context for the `smrt app` command group: source
|
|
182
|
+
* root, application identity, I/O, child-process runners, and the
|
|
183
|
+
* profile-aware runtime environment.
|
|
184
|
+
*/
|
|
185
|
+
/** Locate this package's root (`…/smrt-cli`) from the running module. */
|
|
186
|
+
function cliPackageRoot() {
|
|
187
|
+
let directory = dirname(fileURLToPath(import.meta.url));
|
|
188
|
+
for (let depth = 0; depth < 5; depth += 1) {
|
|
189
|
+
const candidate = join(directory, "package.json");
|
|
190
|
+
if (existsSync(candidate)) try {
|
|
191
|
+
if (JSON.parse(readFileSync(candidate, "utf8")).name === "@happyvertical/smrt-cli") return directory;
|
|
192
|
+
} catch {}
|
|
193
|
+
const parent = dirname(directory);
|
|
194
|
+
if (parent === directory) break;
|
|
195
|
+
directory = parent;
|
|
196
|
+
}
|
|
197
|
+
throw new Error("The smrt CLI package root could not be located.");
|
|
198
|
+
}
|
|
199
|
+
/** Absolute path of the `smrt` executable shipped with this package. */
|
|
200
|
+
function cliExecutablePath() {
|
|
201
|
+
return join(cliPackageRoot(), "bin", "smrt.js");
|
|
202
|
+
}
|
|
203
|
+
/** Absolute path of the web launcher `start` spawns. */
|
|
204
|
+
function webLauncherPath() {
|
|
205
|
+
return join(cliPackageRoot(), "bin", "smrt-web.mjs");
|
|
206
|
+
}
|
|
207
|
+
/** Load `<sourceRoot>/.env` without overriding the shell; a missing file is fine. */
|
|
208
|
+
function loadSourceEnvironment(sourceRoot) {
|
|
209
|
+
try {
|
|
210
|
+
process.loadEnvFile(join(sourceRoot, ".env"));
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function runChild(sourceRoot, binary, args, options = {}) {
|
|
216
|
+
const result = spawnSync(binary, args, {
|
|
217
|
+
cwd: sourceRoot,
|
|
218
|
+
env: options.env || process.env,
|
|
219
|
+
encoding: "utf8",
|
|
220
|
+
shell: options.shell || false,
|
|
221
|
+
stdio: options.capture ? [
|
|
222
|
+
"ignore",
|
|
223
|
+
"pipe",
|
|
224
|
+
"pipe"
|
|
225
|
+
] : "inherit"
|
|
226
|
+
});
|
|
227
|
+
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 });
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
/** Default package-manager runner: honours a pnpm `npm_execpath`. */
|
|
231
|
+
function createPackageManagerRunner(sourceRoot) {
|
|
232
|
+
return (args, options = {}) => {
|
|
233
|
+
const npmExecPath = process.env.npm_execpath;
|
|
234
|
+
const pnpmExecPath = npmExecPath && basename(npmExecPath).toLowerCase().startsWith("pnpm") ? npmExecPath : null;
|
|
235
|
+
return pnpmExecPath ? runChild(sourceRoot, process.execPath, [pnpmExecPath, ...args], options) : runChild(sourceRoot, platform() === "win32" ? "pnpm.cmd" : "pnpm", args, {
|
|
236
|
+
...options,
|
|
237
|
+
shell: platform() === "win32"
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** Default self-runner: this package's own `smrt` executable. */
|
|
242
|
+
function createSmrtRunner(sourceRoot) {
|
|
243
|
+
return (args, options = {}) => runChild(sourceRoot, process.execPath, [cliExecutablePath(), ...args], options);
|
|
244
|
+
}
|
|
245
|
+
/** Default browser opener (`SMRT_OPEN_STUB` writes the URL to a file instead). */
|
|
246
|
+
function createBrowserOpener(sourceRoot) {
|
|
247
|
+
return (url) => {
|
|
248
|
+
if (process.env.SMRT_OPEN_STUB) {
|
|
249
|
+
writeFileSync(resolve(process.env.SMRT_OPEN_STUB), `${url}\n`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const [binary, args] = platform() === "darwin" ? ["open", [url]] : platform() === "win32" ? ["cmd", [
|
|
253
|
+
"/c",
|
|
254
|
+
"start",
|
|
255
|
+
"",
|
|
256
|
+
url
|
|
257
|
+
]] : ["xdg-open", [url]];
|
|
258
|
+
runChild(sourceRoot, binary, args);
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Default runtime resolver: reload `smrt.config` without the cache.
|
|
263
|
+
*
|
|
264
|
+
* Profile selection is `resolveEffectiveApplicationRuntime()` from
|
|
265
|
+
* smrt-config — the rule the web process applies in app-runtime's SvelteKit
|
|
266
|
+
* entry — so the operator and the server it manages never disagree: no
|
|
267
|
+
* `runtime` block (or no config file) is the `local` profile, and a present
|
|
268
|
+
* value that is not a valid block (`null`, `false`, `0`, `''`, ...) fails
|
|
269
|
+
* closed.
|
|
270
|
+
*/
|
|
271
|
+
async function resolveConfiguredRuntime(sourceRoot) {
|
|
272
|
+
const { loadConfig, resolveEffectiveApplicationRuntime } = await import("@happyvertical/smrt-config");
|
|
273
|
+
return resolveEffectiveApplicationRuntime(await loadConfig({
|
|
274
|
+
cache: false,
|
|
275
|
+
searchFrom: sourceRoot
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
/** Build the default dependency set for `sourceRoot`. */
|
|
279
|
+
async function defaultDependencies(sourceRoot) {
|
|
280
|
+
return {
|
|
281
|
+
runtime: await import("@happyvertical/smrt-app-runtime"),
|
|
282
|
+
resolveRuntime: resolveConfiguredRuntime,
|
|
283
|
+
runPackageManager: createPackageManagerRunner(sourceRoot),
|
|
284
|
+
runSmrt: createSmrtRunner(sourceRoot),
|
|
285
|
+
openBrowser: createBrowserOpener(sourceRoot),
|
|
286
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
287
|
+
sleep: (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)),
|
|
288
|
+
signal: (pid, signal) => {
|
|
289
|
+
process.kill(pid, signal);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/** Resolve the application id from `<sourceRoot>/package.json` and `SMRT_APP_ID`. */
|
|
294
|
+
function resolveContextApplicationId(sourceRoot) {
|
|
295
|
+
return resolveApplicationId({
|
|
296
|
+
sourceRoot,
|
|
297
|
+
packageName: JSON.parse(readFileSync(join(sourceRoot, "package.json"), "utf8")).name,
|
|
298
|
+
explicitId: process.env.SMRT_APP_ID
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/** Create or verify the private state root for this invocation. */
|
|
302
|
+
function preparedStateRoot(context) {
|
|
303
|
+
return prepareApplicationStateRoot({
|
|
304
|
+
appId: context.appId,
|
|
305
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
306
|
+
sourceRoot: context.sourceRoot
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/** Compute the environment a web/migration child of this app runs with. */
|
|
310
|
+
function runtimeEnvironment(context, runtime) {
|
|
311
|
+
const env = {
|
|
312
|
+
...process.env,
|
|
313
|
+
SMRT_APP_ID: context.appId,
|
|
314
|
+
SMRT_RUNTIME_PROFILE: runtime.profile,
|
|
315
|
+
HOST: runtime.profile === "local" ? "127.0.0.1" : process.env.HOST || "0.0.0.0",
|
|
316
|
+
PORT: process.env.PORT || "5173"
|
|
317
|
+
};
|
|
318
|
+
if (runtime.profile === "local") {
|
|
319
|
+
const paths = context.deps.runtime.resolveLocalRuntimePaths({
|
|
320
|
+
appId: context.appId,
|
|
321
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
322
|
+
sourceRoot: context.sourceRoot
|
|
323
|
+
});
|
|
324
|
+
env.DATABASE_TYPE = "sqlite";
|
|
325
|
+
env.DATABASE_URL = paths.database;
|
|
326
|
+
env.SMRT_ASSETS_DIR = paths.assets;
|
|
327
|
+
return {
|
|
328
|
+
env,
|
|
329
|
+
paths,
|
|
330
|
+
assetRoot: paths.assets
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (!process.env.DATABASE_URL) throw new Error(`${runtime.profile} requires DATABASE_URL; copy the matching env example and configure providers.`);
|
|
334
|
+
env.DATABASE_TYPE = "postgres";
|
|
335
|
+
let assetRoot = null;
|
|
336
|
+
if (runtime.providers.assets.provider === "local-files") {
|
|
337
|
+
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.");
|
|
338
|
+
assetRoot = assertExternalArtifactPath({
|
|
339
|
+
sourceRoot: context.sourceRoot,
|
|
340
|
+
path: process.env.SMRT_ASSETS_DIR,
|
|
341
|
+
label: "Asset storage root"
|
|
342
|
+
});
|
|
343
|
+
env.SMRT_ASSETS_DIR = assetRoot;
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
env,
|
|
347
|
+
paths: null,
|
|
348
|
+
assetRoot
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
//#endregion
|
|
352
|
+
//#region src/app/launchers.ts
|
|
353
|
+
/**
|
|
354
|
+
* Process wrappers: explicit schema preparation (`migrate`), deployed job
|
|
355
|
+
* workers (`worker`), and the Vite launcher (`dev`, `build`, `vite`).
|
|
356
|
+
*
|
|
357
|
+
* Ported from the template's `scripts/smrt-prepare-migration.mjs`,
|
|
358
|
+
* `scripts/smrt-worker.mjs`, `scripts/smrt-vite.mjs`, and
|
|
359
|
+
* `scripts/smrt-mcp-apps.mjs`.
|
|
360
|
+
*/
|
|
361
|
+
/**
|
|
362
|
+
* Prepare the schema explicitly: under the operation lock, refuse while a
|
|
363
|
+
* local writer is live, establish local storage custody without creating
|
|
364
|
+
* schema or bootstrap records, then run `smrt db:migrate`.
|
|
365
|
+
*/
|
|
366
|
+
async function prepareMigration(context) {
|
|
367
|
+
const runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
368
|
+
const stateRoot = preparedStateRoot(context);
|
|
369
|
+
await withOperationLock(stateRoot, "db:migrate", async () => {
|
|
370
|
+
const env = {
|
|
371
|
+
...process.env,
|
|
372
|
+
SMRT_APP_ID: context.appId
|
|
373
|
+
};
|
|
374
|
+
if (runtime.profile === "local") {
|
|
375
|
+
if (readActiveWriterLease(stateRoot)) throw new Error("Stop the local application before preparing its schema.");
|
|
376
|
+
const paths = context.deps.runtime.resolveLocalRuntimePaths({
|
|
377
|
+
appId: context.appId,
|
|
378
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
379
|
+
sourceRoot: context.sourceRoot
|
|
380
|
+
});
|
|
381
|
+
await context.deps.runtime.prepareLocalDatabaseStorage({
|
|
382
|
+
appId: context.appId,
|
|
383
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
384
|
+
sourceRoot: context.sourceRoot
|
|
385
|
+
});
|
|
386
|
+
env.DATABASE_TYPE = "sqlite";
|
|
387
|
+
env.DATABASE_URL = paths.database;
|
|
388
|
+
env.SMRT_ASSETS_DIR = paths.assets;
|
|
389
|
+
}
|
|
390
|
+
const result = context.deps.runSmrt(["db:migrate"], {
|
|
391
|
+
env,
|
|
392
|
+
allowFailure: true
|
|
393
|
+
});
|
|
394
|
+
if (result.error || result.status !== 0) throw new AppCommandError("s-m-r-t database migration failed.", result.status ?? 1, { cause: result.error });
|
|
395
|
+
});
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
var WORKER_USAGE = "Usage: smrt app worker [task|schedule]";
|
|
399
|
+
/** Parse `smrt app worker [kind]`, refusing anything but `task`/`schedule`. */
|
|
400
|
+
function parseWorkerKind(args) {
|
|
401
|
+
if (args.length > 1) throw new AppCommandError(WORKER_USAGE, 2);
|
|
402
|
+
const kind = args[0] ?? "task";
|
|
403
|
+
if (kind !== "task" && kind !== "schedule") throw new AppCommandError(WORKER_USAGE, 2);
|
|
404
|
+
return kind;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Start a deployed job worker. Resolves once the runner is started; the
|
|
408
|
+
* process stays alive until SIGINT/SIGTERM closes the runtime.
|
|
409
|
+
*/
|
|
410
|
+
async function runWorker(context, args) {
|
|
411
|
+
const kind = parseWorkerKind(args);
|
|
412
|
+
const configured = await context.deps.resolveRuntime(context.sourceRoot);
|
|
413
|
+
if (configured.profile === "local") throw new Error("Local jobs run inline or embedded; a separate worker requires self-hosted or cloud.");
|
|
414
|
+
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required.");
|
|
415
|
+
const registration = join(context.sourceRoot, ".smrt", "runtime", "register.js");
|
|
416
|
+
if (!existsSync(registration)) throw new Error("Missing .smrt/runtime/register.js: run pnpm build before starting a worker.");
|
|
417
|
+
await import(pathToFileURL(registration).href);
|
|
418
|
+
const probe = (component) => createProviderReadinessProbe(component, {
|
|
419
|
+
profile: configured.profile,
|
|
420
|
+
provider: configured.providers[component].provider
|
|
421
|
+
}, { sourceRoot: context.sourceRoot });
|
|
422
|
+
const runtime = await context.deps.runtime.initializeDeployedApplicationRuntime({
|
|
423
|
+
profile: configured.profile,
|
|
424
|
+
providers: {
|
|
425
|
+
database: configured.providers.database,
|
|
426
|
+
authentication: configured.providers.authentication,
|
|
427
|
+
tenancy: configured.providers.tenancy,
|
|
428
|
+
assets: configured.providers.assets,
|
|
429
|
+
secrets: configured.providers.secrets,
|
|
430
|
+
jobs: configured.providers.jobs,
|
|
431
|
+
network: configured.providers.network
|
|
432
|
+
},
|
|
433
|
+
database: {
|
|
434
|
+
engine: "postgres",
|
|
435
|
+
connect: async () => {
|
|
436
|
+
const { getDatabase } = await import("@happyvertical/sql");
|
|
437
|
+
return await getDatabase({
|
|
438
|
+
type: "postgres",
|
|
439
|
+
url: process.env.DATABASE_URL
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
close: async (db) => {
|
|
443
|
+
await db.close?.();
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
authentication: {
|
|
447
|
+
provider: configured.providers.authentication.provider,
|
|
448
|
+
readiness: probe("authentication")
|
|
449
|
+
},
|
|
450
|
+
assets: {
|
|
451
|
+
provider: configured.providers.assets.provider,
|
|
452
|
+
readiness: probe("assets")
|
|
453
|
+
},
|
|
454
|
+
secrets: {
|
|
455
|
+
provider: configured.providers.secrets.provider,
|
|
456
|
+
readiness: probe("secrets")
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
await (kind === "schedule" ? await runtime.createScheduleWorker() : await runtime.createTaskWorker({ concurrency: Number.parseInt(process.env.SMRT_WORKER_CONCURRENCY || "4", 10) })).start();
|
|
460
|
+
context.io.stdout(`${JSON.stringify({
|
|
461
|
+
schemaVersion: 1,
|
|
462
|
+
status: "ready",
|
|
463
|
+
kind,
|
|
464
|
+
secretValuesIncluded: false
|
|
465
|
+
})}\n`);
|
|
466
|
+
let stopping = false;
|
|
467
|
+
const stop = async () => {
|
|
468
|
+
if (stopping) return;
|
|
469
|
+
stopping = true;
|
|
470
|
+
await runtime.close();
|
|
471
|
+
};
|
|
472
|
+
process.once("SIGINT", () => void stop());
|
|
473
|
+
process.once("SIGTERM", () => void stop());
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
/** Failure of the Vite launcher; rendered as `s-m-r-t Vite launcher: …`. */
|
|
477
|
+
var ViteLauncherError = class extends Error {};
|
|
478
|
+
/** Resolve the application's installed Vite CLI entry, refusing unsafe paths. */
|
|
479
|
+
function resolveViteEntry(sourceRoot) {
|
|
480
|
+
const installed = findInstalledPackage(sourceRoot, "vite");
|
|
481
|
+
if (!installed) throw new ViteLauncherError("Could not resolve the installed Vite package. Verify the generated app dependencies and Vite compatibility.");
|
|
482
|
+
const packagePath = join(realpathSync(installed), "package.json");
|
|
483
|
+
const packageRoot = dirname(packagePath);
|
|
484
|
+
let metadata;
|
|
485
|
+
try {
|
|
486
|
+
metadata = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
487
|
+
} catch {
|
|
488
|
+
throw new ViteLauncherError("The installed Vite package metadata is invalid; reinstall dependencies.");
|
|
489
|
+
}
|
|
490
|
+
const declaredBin = typeof metadata.bin === "string" ? metadata.bin : metadata.bin?.vite;
|
|
491
|
+
if (typeof declaredBin !== "string" || declaredBin === "") throw new ViteLauncherError("The installed Vite package does not declare its CLI.");
|
|
492
|
+
const entry = resolve(packageRoot, declaredBin);
|
|
493
|
+
const relativeEntry = relative(packageRoot, entry);
|
|
494
|
+
if (relativeEntry === "" || relativeEntry.startsWith("..") || isAbsolute(relativeEntry)) throw new ViteLauncherError("The installed Vite package declares an unsafe CLI path.");
|
|
495
|
+
if (!existsSync(entry)) throw new ViteLauncherError("The installed Vite package CLI is missing; reinstall dependencies.");
|
|
496
|
+
return entry;
|
|
497
|
+
}
|
|
498
|
+
/** Loopback host every `smrt app` URL (onboarding, `open`, `start`) names. */
|
|
499
|
+
var LOOPBACK_HOST = "127.0.0.1";
|
|
500
|
+
/**
|
|
501
|
+
* Arguments for `smrt app dev`: the caller's Vite arguments, plus
|
|
502
|
+
* `--host 127.0.0.1` unless they name `--host`, and `--port $PORT` when
|
|
503
|
+
* `PORT` is set and they name no `--port`. Vite's own default host is
|
|
504
|
+
* `localhost`, which Node resolves to `[::1]` first, so the dev server would
|
|
505
|
+
* not answer the `http://127.0.0.1:<PORT>` URL onboarding and `app:open` hand
|
|
506
|
+
* out. An explicit `--host`/`--port` (or `smrt app vite dev …`) wins.
|
|
507
|
+
*/
|
|
508
|
+
function devServerArguments(args, env = process.env) {
|
|
509
|
+
const names = (flag) => args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
510
|
+
const result = [...args];
|
|
511
|
+
if (!names("--host")) result.push("--host", LOOPBACK_HOST);
|
|
512
|
+
if (!names("--port") && env.PORT) result.push("--port", env.PORT);
|
|
513
|
+
return result;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Run the application's Vite CLI in this process with `args` verbatim. The
|
|
517
|
+
* caller has already loaded `.env` without overriding the shell.
|
|
518
|
+
*/
|
|
519
|
+
async function launchVite(context, args) {
|
|
520
|
+
let entry;
|
|
521
|
+
try {
|
|
522
|
+
entry = resolveViteEntry(context.sourceRoot);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (error instanceof ViteLauncherError) {
|
|
525
|
+
context.io.stderr(`s-m-r-t Vite launcher: ${error.message}\n`);
|
|
526
|
+
return 1;
|
|
527
|
+
}
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
process.argv = [
|
|
531
|
+
process.execPath,
|
|
532
|
+
entry,
|
|
533
|
+
...args
|
|
534
|
+
];
|
|
535
|
+
await import(pathToFileURL(entry).href);
|
|
536
|
+
return 0;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Validate opt-in portable MCP Apps metadata (`./mcp-apps`) when present.
|
|
540
|
+
* Returns the exit code; findings go to stderr.
|
|
541
|
+
*/
|
|
542
|
+
async function validateMcpAppsIfPresent(context) {
|
|
543
|
+
const root = join(context.sourceRoot, "mcp-apps");
|
|
544
|
+
if (!existsSync(root)) return 0;
|
|
545
|
+
const { validateMcpAppsPackage } = await import("./mcp-apps-packaging-CixRnxz_.js");
|
|
546
|
+
const result = validateMcpAppsPackage(root);
|
|
547
|
+
for (const finding of result.findings) context.io.stderr(`${finding.code}: ${finding.message}\n`);
|
|
548
|
+
if (!result.valid) {
|
|
549
|
+
context.io.stderr(`Error: MCP Apps package validation failed with ${result.findings.length} finding(s)\n`);
|
|
550
|
+
return 1;
|
|
551
|
+
}
|
|
552
|
+
context.io.stdout("MCP Apps package at ./mcp-apps is valid.\n");
|
|
553
|
+
return 0;
|
|
554
|
+
}
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region src/app/process-record.ts
|
|
557
|
+
/**
|
|
558
|
+
* Managed web-process record (`<state>/app.pid`).
|
|
559
|
+
*
|
|
560
|
+
* Ported from the template's `scripts/smrt-process.mjs`. A recorded pid is
|
|
561
|
+
* only trusted when the live process's command line proves it is the web
|
|
562
|
+
* launcher with this record's instance nonce, so a recycled pid is never
|
|
563
|
+
* signalled.
|
|
564
|
+
*/
|
|
565
|
+
/** File name of the web launcher; part of the process-identity proof. */
|
|
566
|
+
var WEB_LAUNCHER_NAME = "smrt-web.mjs";
|
|
567
|
+
function processCommand(pid) {
|
|
568
|
+
if (platform() === "win32") {
|
|
569
|
+
const result = spawnSync("powershell.exe", [
|
|
570
|
+
"-NoProfile",
|
|
571
|
+
"-NonInteractive",
|
|
572
|
+
"-Command",
|
|
573
|
+
`(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`
|
|
574
|
+
], {
|
|
575
|
+
encoding: "utf8",
|
|
576
|
+
windowsHide: true
|
|
577
|
+
});
|
|
578
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
579
|
+
}
|
|
580
|
+
const result = spawnSync("ps", [
|
|
581
|
+
"-p",
|
|
582
|
+
String(pid),
|
|
583
|
+
"-o",
|
|
584
|
+
"command="
|
|
585
|
+
], { encoding: "utf8" });
|
|
586
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
587
|
+
}
|
|
588
|
+
/** Write the private (mode 0600) process record. */
|
|
589
|
+
function writeProcessRecord(path, record) {
|
|
590
|
+
writeFileSync(path, `${JSON.stringify(record)}\n`, { mode: 384 });
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Send SIGTERM. Returns `false` when the process had already exited; any
|
|
594
|
+
* other failure (e.g. EPERM) is rethrown.
|
|
595
|
+
*/
|
|
596
|
+
function sendTerminationSignal(pid, killProcess = process.kill) {
|
|
597
|
+
try {
|
|
598
|
+
killProcess(pid, "SIGTERM");
|
|
599
|
+
return true;
|
|
600
|
+
} catch (error) {
|
|
601
|
+
if (errorCode(error) === "ESRCH") return false;
|
|
602
|
+
throw error;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/** Split a process command line into argv-like tokens, dropping quotes. */
|
|
606
|
+
function commandTokens(command) {
|
|
607
|
+
return command.trim().split(/\s+/).map((token) => token.replace(/^"|"$/g, "")).filter(Boolean);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* True when `command` is exactly the web launcher started with this record's
|
|
611
|
+
* nonce: the final argument is `--smrt-instance=<instance>` and the argument
|
|
612
|
+
* before it is a path whose basename is `smrt-web.mjs` (the CLI's
|
|
613
|
+
* `bin/smrt-web.mjs`, or the template's `scripts/smrt-web.mjs`). Substring
|
|
614
|
+
* matches such as `evil-smrt-web.mjs` or a nonce with a suffix are rejected.
|
|
615
|
+
*/
|
|
616
|
+
function matchesApplicationProcess(record, command) {
|
|
617
|
+
if (typeof command !== "string") return false;
|
|
618
|
+
const tokens = commandTokens(command);
|
|
619
|
+
if (tokens.length < 2) return false;
|
|
620
|
+
const script = tokens[tokens.length - 2];
|
|
621
|
+
return tokens[tokens.length - 1] === `--smrt-instance=${record.instance}` && script.split(/[\\/]/).pop() === "smrt-web.mjs";
|
|
622
|
+
}
|
|
623
|
+
function isLive(pid) {
|
|
624
|
+
try {
|
|
625
|
+
process.kill(pid, 0);
|
|
626
|
+
return true;
|
|
627
|
+
} catch (error) {
|
|
628
|
+
const code = errorCode(error);
|
|
629
|
+
return code === "EPERM" || code === "EACCES";
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Re-check `record` against the live process table. Only `gone` and
|
|
634
|
+
* `mismatched` justify dropping the record; `unverifiable` (the process
|
|
635
|
+
* query failed or returned nothing) must keep it and must not signal.
|
|
636
|
+
*/
|
|
637
|
+
function checkOwnedProcess(record) {
|
|
638
|
+
if (!isLive(record.pid)) return "gone";
|
|
639
|
+
const command = processCommand(record.pid);
|
|
640
|
+
if (command === null || command === "") return isLive(record.pid) ? "unverifiable" : "gone";
|
|
641
|
+
return matchesApplicationProcess(record, command) ? "owned" : "mismatched";
|
|
642
|
+
}
|
|
643
|
+
/** True only when {@link checkOwnedProcess} proves the record still owned. */
|
|
644
|
+
function verifyOwnedProcess(record) {
|
|
645
|
+
return checkOwnedProcess(record) === "owned";
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Return the recorded web process when it is live and provably ours;
|
|
649
|
+
* otherwise remove the stale record and return `null`.
|
|
650
|
+
*/
|
|
651
|
+
function readOwnedProcess(path) {
|
|
652
|
+
let record;
|
|
653
|
+
try {
|
|
654
|
+
record = JSON.parse(readFileSync(path, "utf8"));
|
|
655
|
+
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.");
|
|
656
|
+
} catch {
|
|
657
|
+
rmSync(path, { force: true });
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
process.kill(record.pid, 0);
|
|
662
|
+
} catch (error) {
|
|
663
|
+
const code = errorCode(error);
|
|
664
|
+
if (code !== "EPERM" && code !== "EACCES") {
|
|
665
|
+
rmSync(path, { force: true });
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const command = processCommand(record.pid);
|
|
670
|
+
if (command === null || command === "") throw new Error(`Application process ${record.pid} is live but its identity cannot be verified.`);
|
|
671
|
+
if (!matchesApplicationProcess(record, command)) {
|
|
672
|
+
rmSync(path, { force: true });
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
return record;
|
|
676
|
+
}
|
|
677
|
+
//#endregion
|
|
678
|
+
//#region src/app/operations.ts
|
|
679
|
+
/**
|
|
680
|
+
* Local/deployed application operations: install, setup, recover, start,
|
|
681
|
+
* stop, doctor, open, backup, export, import.
|
|
682
|
+
*
|
|
683
|
+
* Ported from the template's `scripts/smrt-app.mjs`. Output JSON, exit
|
|
684
|
+
* codes, state files, and environment variables are unchanged so the
|
|
685
|
+
* template's `pnpm app:*` scripts can become `smrt app <operation>`.
|
|
686
|
+
*/
|
|
687
|
+
/** Operations implemented by this module. */
|
|
688
|
+
var APP_OPERATIONS = [
|
|
689
|
+
"install",
|
|
690
|
+
"setup",
|
|
691
|
+
"recover",
|
|
692
|
+
"start",
|
|
693
|
+
"stop",
|
|
694
|
+
"doctor",
|
|
695
|
+
"open",
|
|
696
|
+
"backup",
|
|
697
|
+
"export",
|
|
698
|
+
"import"
|
|
699
|
+
];
|
|
700
|
+
function printJson$1(context, value, pretty = false) {
|
|
701
|
+
context.io.stdout(`${pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)}\n`);
|
|
702
|
+
}
|
|
703
|
+
/** Print the one-time URL to the operator's terminal only, never to logs. */
|
|
704
|
+
function printOnboardingUrl(context, url) {
|
|
705
|
+
if (!url || !context.io.operatorTerminal) return;
|
|
706
|
+
context.io.operatorTerminal(`Open this one-time owner setup link on this device (it works once and expires):\n ${url}\n`);
|
|
707
|
+
}
|
|
708
|
+
function ensurePrivateDirectory(path) {
|
|
709
|
+
mkdirSync(path, {
|
|
710
|
+
recursive: true,
|
|
711
|
+
mode: 448
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function nearestExistingAncestor(path) {
|
|
715
|
+
let candidate = path;
|
|
716
|
+
while (!existsSync(candidate)) {
|
|
717
|
+
const parent = dirname(candidate);
|
|
718
|
+
if (parent === candidate) return candidate;
|
|
719
|
+
candidate = parent;
|
|
720
|
+
}
|
|
721
|
+
return candidate;
|
|
722
|
+
}
|
|
723
|
+
function pidPath(context) {
|
|
724
|
+
return join(preparedStateRoot(context), "app.pid");
|
|
725
|
+
}
|
|
726
|
+
function onboardingPath(context) {
|
|
727
|
+
return join(preparedStateRoot(context), "onboarding.json");
|
|
728
|
+
}
|
|
729
|
+
function onboardingLaunchPath(context) {
|
|
730
|
+
return join(preparedStateRoot(context), "onboarding-launch.html");
|
|
731
|
+
}
|
|
732
|
+
function removeOnboardingHandoff(context) {
|
|
733
|
+
rmSync(onboardingPath(context), { force: true });
|
|
734
|
+
rmSync(onboardingLaunchPath(context), { force: true });
|
|
735
|
+
}
|
|
736
|
+
function writePrivateAtomic(destination, contents) {
|
|
737
|
+
const temporary = `${destination}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
738
|
+
try {
|
|
739
|
+
writeFileSync(temporary, contents, {
|
|
740
|
+
flag: "wx",
|
|
741
|
+
mode: 384
|
|
742
|
+
});
|
|
743
|
+
renameSync(temporary, destination);
|
|
744
|
+
} finally {
|
|
745
|
+
rmSync(temporary, { force: true });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
function saveOnboardingLaunch(context, url) {
|
|
749
|
+
const escapedUrl = url.replaceAll("&", "&").replaceAll("\"", """);
|
|
750
|
+
writePrivateAtomic(onboardingLaunchPath(context), `<!doctype html><meta http-equiv="refresh" content="0;url=${escapedUrl}">\n`);
|
|
751
|
+
}
|
|
752
|
+
function saveOnboardingUrl(context, url) {
|
|
753
|
+
ensurePrivateDirectory(preparedStateRoot(context));
|
|
754
|
+
saveOnboardingLaunch(context, url);
|
|
755
|
+
writePrivateAtomic(onboardingPath(context), `${JSON.stringify({
|
|
756
|
+
schemaVersion: 1,
|
|
757
|
+
url
|
|
758
|
+
})}\n`);
|
|
759
|
+
}
|
|
760
|
+
var InvalidOnboardingHandoff = class extends Error {};
|
|
761
|
+
/** Read the retained loopback onboarding URL; tampered handoffs are removed. */
|
|
762
|
+
function readOnboardingUrl(context) {
|
|
763
|
+
try {
|
|
764
|
+
const value = JSON.parse(readFileSync(onboardingPath(context), "utf8"));
|
|
765
|
+
if (value?.schemaVersion !== 1 || typeof value.url !== "string") throw new InvalidOnboardingHandoff("Invalid onboarding handoff.");
|
|
766
|
+
const url = new URL(value.url);
|
|
767
|
+
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" && url.hostname !== "localhost" || url.pathname !== "/setup") throw new InvalidOnboardingHandoff("Invalid onboarding handoff.");
|
|
768
|
+
return url.toString();
|
|
769
|
+
} catch (error) {
|
|
770
|
+
if (errorCode(error) === "ENOENT" || error instanceof SyntaxError || error instanceof InvalidOnboardingHandoff || errorCode(error) === "ERR_INVALID_URL") {
|
|
771
|
+
removeOnboardingHandoff(context);
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
/** Throw unless the configured profile is `local`. */
|
|
778
|
+
async function assertLocalOperation(context, operation) {
|
|
779
|
+
const runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
780
|
+
if (runtime.profile !== "local") throw new Error(`${operation} is local-profile only; run the production Node build or container for deployed profiles.`);
|
|
781
|
+
return runtime;
|
|
782
|
+
}
|
|
783
|
+
async function initializeLocal(context, runtime, env, options = {}) {
|
|
784
|
+
if (runtime.profile !== "local") return null;
|
|
785
|
+
return context.deps.runtime.initializeLocalApplicationRuntime({
|
|
786
|
+
appId: context.appId,
|
|
787
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
788
|
+
sourceRoot: context.sourceRoot,
|
|
789
|
+
bindHost: env.HOST || "127.0.0.1",
|
|
790
|
+
providers: {
|
|
791
|
+
database: runtime.providers.database,
|
|
792
|
+
authentication: runtime.providers.authentication,
|
|
793
|
+
tenancy: runtime.providers.tenancy,
|
|
794
|
+
assets: runtime.providers.assets,
|
|
795
|
+
secrets: runtime.providers.secrets,
|
|
796
|
+
jobs: runtime.providers.jobs,
|
|
797
|
+
network: runtime.providers.network
|
|
798
|
+
},
|
|
799
|
+
prepareDatabase: options.prepareDatabase ? async () => {
|
|
800
|
+
context.deps.runSmrt(["db:migrate"], {
|
|
801
|
+
env,
|
|
802
|
+
label: "smrt db:migrate"
|
|
803
|
+
});
|
|
804
|
+
} : void 0,
|
|
805
|
+
backgroundJobs: process.env.SMRT_BACKGROUND_JOBS === "true"
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
async function setup(context, operationLock) {
|
|
809
|
+
const runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
810
|
+
const operatorLease = runtime.profile === "local" ? acquireWriterLease(preparedStateRoot(context), { operationInstance: operationLock?.instance }) : null;
|
|
811
|
+
try {
|
|
812
|
+
const { env } = runtimeEnvironment(context, runtime);
|
|
813
|
+
context.deps.runPackageManager(["build"], {
|
|
814
|
+
env,
|
|
815
|
+
label: "pnpm build"
|
|
816
|
+
});
|
|
817
|
+
let initialized = null;
|
|
818
|
+
if (runtime.profile === "local") initialized = await initializeLocal(context, runtime, env, { prepareDatabase: true });
|
|
819
|
+
else context.deps.runSmrt(["db:migrate"], {
|
|
820
|
+
env,
|
|
821
|
+
label: "smrt db:migrate"
|
|
822
|
+
});
|
|
823
|
+
const port = env.PORT || "5173";
|
|
824
|
+
const onboardingUrl = initialized?.bootstrap ? `http://127.0.0.1:${port}/setup?token=${encodeURIComponent(initialized.bootstrap.token)}` : null;
|
|
825
|
+
const diagnostics = await initialized?.runtime.diagnostics();
|
|
826
|
+
if (onboardingUrl) saveOnboardingUrl(context, onboardingUrl);
|
|
827
|
+
if (diagnostics?.bootstrap.status === "claimed") removeOnboardingHandoff(context);
|
|
828
|
+
await initialized?.runtime.db.close?.();
|
|
829
|
+
const retainedOnboardingUrl = onboardingUrl || readOnboardingUrl(context);
|
|
830
|
+
const onboardingAvailable = runtime.profile === "local" && retainedOnboardingUrl !== null;
|
|
831
|
+
const report = {
|
|
832
|
+
schemaVersion: 1,
|
|
833
|
+
status: "ready",
|
|
834
|
+
profile: runtime.profile,
|
|
835
|
+
onboardingAvailable,
|
|
836
|
+
onboardingRecovery: onboardingAvailable ? "pnpm app:open" : null,
|
|
837
|
+
secretValuesIncluded: false
|
|
838
|
+
};
|
|
839
|
+
printJson$1(context, report, true);
|
|
840
|
+
return {
|
|
841
|
+
...report,
|
|
842
|
+
onboardingUrl: retainedOnboardingUrl
|
|
843
|
+
};
|
|
844
|
+
} finally {
|
|
845
|
+
operatorLease?.release();
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
async function recoverOnboarding(context, operationLock) {
|
|
849
|
+
const runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
850
|
+
const { env } = runtimeEnvironment(context, runtime);
|
|
851
|
+
if (runtime.profile !== "local") throw new Error("Owner onboarding recovery is local-only.");
|
|
852
|
+
const operatorLease = acquireWriterLease(preparedStateRoot(context), { operationInstance: operationLock?.instance });
|
|
853
|
+
try {
|
|
854
|
+
const initialized = await initializeLocal(context, runtime, env);
|
|
855
|
+
if (!initialized) throw new Error("Local runtime initialization failed.");
|
|
856
|
+
const invitation = await initialized.runtime.rotateBootstrapInvitation();
|
|
857
|
+
const url = `http://127.0.0.1:${env.PORT || "5173"}/setup?token=${encodeURIComponent(invitation.token)}`;
|
|
858
|
+
saveOnboardingUrl(context, url);
|
|
859
|
+
await initialized.runtime.db.close?.();
|
|
860
|
+
printJson$1(context, {
|
|
861
|
+
schemaVersion: 1,
|
|
862
|
+
status: "ready",
|
|
863
|
+
onboardingAvailable: true,
|
|
864
|
+
recovery: "Run pnpm app:start, then pnpm app:open.",
|
|
865
|
+
secretValuesIncluded: false
|
|
866
|
+
});
|
|
867
|
+
printOnboardingUrl(context, url);
|
|
868
|
+
} finally {
|
|
869
|
+
operatorLease.release();
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
function isAlive(pid) {
|
|
873
|
+
process.kill(pid, 0);
|
|
874
|
+
return true;
|
|
875
|
+
}
|
|
876
|
+
async function waitForReady(context, url, pid, instance, configuration) {
|
|
877
|
+
const healthUrl = new URL("/api/_runtime/health", url);
|
|
878
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
879
|
+
try {
|
|
880
|
+
const response = await context.deps.fetch(healthUrl, { redirect: "manual" });
|
|
881
|
+
if (response.status === 200) {
|
|
882
|
+
const health = await response.json();
|
|
883
|
+
if (health?.status === "ready" && health.application === context.appId && health.instance === instance && health.configuration === configuration) {
|
|
884
|
+
isAlive(pid);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
isAlive(pid);
|
|
889
|
+
} catch {
|
|
890
|
+
try {
|
|
891
|
+
isAlive(pid);
|
|
892
|
+
} catch {
|
|
893
|
+
throw new Error("The application process exited before becoming ready.");
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
await context.deps.sleep(250);
|
|
897
|
+
}
|
|
898
|
+
throw new Error(`The application did not become ready at ${url}.`);
|
|
899
|
+
}
|
|
900
|
+
async function start(context, operationLock) {
|
|
901
|
+
const runtime = await assertLocalOperation(context, "app:start");
|
|
902
|
+
const { env } = runtimeEnvironment(context, runtime);
|
|
903
|
+
const url = `http://127.0.0.1:${env.PORT || "5173"}/`;
|
|
904
|
+
env.ORIGIN ||= url;
|
|
905
|
+
const configuration = runtimeConfigurationFingerprint(runtime, env);
|
|
906
|
+
const existing = readOwnedProcess(pidPath(context));
|
|
907
|
+
if (existing) {
|
|
908
|
+
await waitForReady(context, url, existing.pid, existing.instance, configuration);
|
|
909
|
+
printJson$1(context, {
|
|
910
|
+
schemaVersion: 1,
|
|
911
|
+
status: "running",
|
|
912
|
+
pid: existing.pid
|
|
913
|
+
});
|
|
914
|
+
return existing.pid;
|
|
915
|
+
}
|
|
916
|
+
ensurePrivateDirectory(preparedStateRoot(context));
|
|
917
|
+
const instance = randomBytes(16).toString("hex");
|
|
918
|
+
const childEnv = {
|
|
919
|
+
...env,
|
|
920
|
+
HOST: runtime.profile === "local" ? "127.0.0.1" : env.HOST || "0.0.0.0",
|
|
921
|
+
PORT: env.PORT || "5173",
|
|
922
|
+
SMRT_PROCESS_INSTANCE: instance,
|
|
923
|
+
SMRT_OPERATION_INSTANCE: operationLock?.instance
|
|
924
|
+
};
|
|
925
|
+
const logFile = startLogPath(context);
|
|
926
|
+
const logFd = openStartLog(logFile);
|
|
927
|
+
let child;
|
|
928
|
+
try {
|
|
929
|
+
child = spawn(process.execPath, [webLauncherPath(), `--smrt-instance=${instance}`], {
|
|
930
|
+
cwd: context.sourceRoot,
|
|
931
|
+
env: childEnv,
|
|
932
|
+
detached: true,
|
|
933
|
+
stdio: [
|
|
934
|
+
"ignore",
|
|
935
|
+
logFd,
|
|
936
|
+
logFd
|
|
937
|
+
]
|
|
938
|
+
});
|
|
939
|
+
} finally {
|
|
940
|
+
closeSync(logFd);
|
|
941
|
+
}
|
|
942
|
+
child.unref();
|
|
943
|
+
const pid = child.pid;
|
|
944
|
+
if (pid === void 0) throw new Error("The application process could not be started.");
|
|
945
|
+
writeProcessRecord(pidPath(context), {
|
|
946
|
+
pid,
|
|
947
|
+
instance
|
|
948
|
+
});
|
|
949
|
+
try {
|
|
950
|
+
await waitForReady(context, url, pid, instance, configuration);
|
|
951
|
+
} catch (error) {
|
|
952
|
+
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.`);
|
|
953
|
+
rmSync(pidPath(context), { force: true });
|
|
954
|
+
throw new ApplicationStartError(error instanceof Error ? error.message : "The application did not become ready.", redactedLogTail(logFile, childEnv), logFile);
|
|
955
|
+
}
|
|
956
|
+
printJson$1(context, {
|
|
957
|
+
schemaVersion: 1,
|
|
958
|
+
status: "started",
|
|
959
|
+
pid
|
|
960
|
+
});
|
|
961
|
+
return pid;
|
|
962
|
+
}
|
|
963
|
+
/** Upper bound on the web-process output a failed `start` reports. */
|
|
964
|
+
var START_OUTPUT_TAIL_BYTES = 8 * 1024;
|
|
965
|
+
/** Window read before redaction, so a cut never splits a reported secret. */
|
|
966
|
+
var START_OUTPUT_WINDOW_BYTES = 64 * 1024;
|
|
967
|
+
function startLogPath(context) {
|
|
968
|
+
return join(preparedStateRoot(context), "app.log");
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Create a fresh 0600 `app.log` for this start. The previous run's log is
|
|
972
|
+
* removed first and the new one is created exclusively, so a planted link or
|
|
973
|
+
* file is never written through.
|
|
974
|
+
*/
|
|
975
|
+
function openStartLog(path) {
|
|
976
|
+
rmSync(path, { force: true });
|
|
977
|
+
return openSync(path, "wx", 384);
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* The last {@link START_OUTPUT_TAIL_BYTES} of `path`, strictly redacted
|
|
981
|
+
* against the child's environment. A bounded window is redacted before it is cut, and a
|
|
982
|
+
* window that starts mid-file drops its leading partial record (all of it
|
|
983
|
+
* when the window holds no newline), so no fragment of a secret whose
|
|
984
|
+
* prefix lies before the window can survive the cut.
|
|
985
|
+
*/
|
|
986
|
+
function redactedLogTail(path, env) {
|
|
987
|
+
let text;
|
|
988
|
+
try {
|
|
989
|
+
const fd = openSync(path, "r");
|
|
990
|
+
try {
|
|
991
|
+
const size = fstatSync(fd).size;
|
|
992
|
+
const length = Math.min(size, START_OUTPUT_WINDOW_BYTES);
|
|
993
|
+
const buffer = Buffer.alloc(length);
|
|
994
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
995
|
+
text = buffer.toString("utf8");
|
|
996
|
+
if (length < size) {
|
|
997
|
+
const newline = text.indexOf("\n");
|
|
998
|
+
text = newline === -1 ? "" : text.slice(newline + 1);
|
|
999
|
+
}
|
|
1000
|
+
} finally {
|
|
1001
|
+
closeSync(fd);
|
|
1002
|
+
}
|
|
1003
|
+
} catch {
|
|
1004
|
+
return "";
|
|
1005
|
+
}
|
|
1006
|
+
return boundedTail(redactSecrets(text, env, { strict: true }), START_OUTPUT_TAIL_BYTES);
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Terminate a launcher that failed to prove readiness: SIGTERM, wait, then
|
|
1010
|
+
* SIGKILL, wait. Resolves `true` only once the child has provably exited.
|
|
1011
|
+
* Uses the child handle we own, so a reaped pid is never signalled.
|
|
1012
|
+
*/
|
|
1013
|
+
async function terminateFailedStart(context, child) {
|
|
1014
|
+
const exited = () => child.exitCode !== null || child.signalCode !== null;
|
|
1015
|
+
for (const [signal, attempts] of [["SIGTERM", 50], ["SIGKILL", 20]]) {
|
|
1016
|
+
if (exited()) return true;
|
|
1017
|
+
try {
|
|
1018
|
+
context.deps.signal(child.pid, signal);
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
if (errorCode(error) === "ESRCH") return true;
|
|
1021
|
+
throw error;
|
|
1022
|
+
}
|
|
1023
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1024
|
+
await context.deps.sleep(100);
|
|
1025
|
+
if (exited()) return true;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return exited();
|
|
1029
|
+
}
|
|
1030
|
+
async function stop(context) {
|
|
1031
|
+
const record = readOwnedProcess(pidPath(context));
|
|
1032
|
+
const pid = record?.pid || null;
|
|
1033
|
+
if (!record || !pid) {
|
|
1034
|
+
rmSync(pidPath(context), { force: true });
|
|
1035
|
+
printJson$1(context, {
|
|
1036
|
+
schemaVersion: 1,
|
|
1037
|
+
status: "stopped"
|
|
1038
|
+
});
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
const state = checkOwnedProcess(record);
|
|
1042
|
+
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.`);
|
|
1043
|
+
if (state !== "owned" || !sendTerminationSignal(pid)) {
|
|
1044
|
+
rmSync(pidPath(context), { force: true });
|
|
1045
|
+
printJson$1(context, {
|
|
1046
|
+
schemaVersion: 1,
|
|
1047
|
+
status: "stopped",
|
|
1048
|
+
pid
|
|
1049
|
+
});
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
1053
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
|
|
1054
|
+
try {
|
|
1055
|
+
process.kill(pid, 0);
|
|
1056
|
+
} catch {
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
if (attempt === 39) throw new Error(`Application process ${pid} did not stop cleanly.`);
|
|
1060
|
+
}
|
|
1061
|
+
rmSync(pidPath(context), { force: true });
|
|
1062
|
+
printJson$1(context, {
|
|
1063
|
+
schemaVersion: 1,
|
|
1064
|
+
status: "stopped",
|
|
1065
|
+
pid
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
async function open(context) {
|
|
1069
|
+
const runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
1070
|
+
const url = `http://${runtime.profile === "local" ? "127.0.0.1" : process.env.HOST || "127.0.0.1"}:${process.env.PORT || "5173"}/`;
|
|
1071
|
+
const onboardingUrl = runtime.profile === "local" ? readOnboardingUrl(context) : null;
|
|
1072
|
+
if (onboardingUrl) saveOnboardingLaunch(context, onboardingUrl);
|
|
1073
|
+
const destination = onboardingUrl ? pathToFileURL(onboardingLaunchPath(context)).toString() : url;
|
|
1074
|
+
context.deps.openBrowser(destination);
|
|
1075
|
+
printJson$1(context, {
|
|
1076
|
+
schemaVersion: 1,
|
|
1077
|
+
status: "opened",
|
|
1078
|
+
url
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
async function doctor(context) {
|
|
1082
|
+
const findings = [];
|
|
1083
|
+
let runtime = null;
|
|
1084
|
+
let paths = null;
|
|
1085
|
+
let statePath = null;
|
|
1086
|
+
let canInspectMigrations = false;
|
|
1087
|
+
try {
|
|
1088
|
+
runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
1089
|
+
} catch {
|
|
1090
|
+
findings.push({
|
|
1091
|
+
code: "invalid-runtime-profile",
|
|
1092
|
+
severity: "error",
|
|
1093
|
+
message: "The canonical runtime profile is invalid.",
|
|
1094
|
+
recovery: "Select local, self-hosted, or cloud in smrt.config.ts."
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
if (Number(process.versions.node.split(".")[0]) < 26) findings.push({
|
|
1098
|
+
code: "unsupported-node",
|
|
1099
|
+
severity: "error",
|
|
1100
|
+
message: "Node.js 26 or newer is required.",
|
|
1101
|
+
recovery: "Install the Node.js version declared in package.json engines."
|
|
1102
|
+
});
|
|
1103
|
+
if (runtime) {
|
|
1104
|
+
if (runtime.profile !== "local") for (const [component, provider] of [
|
|
1105
|
+
["authentication", runtime.providers.authentication.provider],
|
|
1106
|
+
["assets", runtime.providers.assets.provider],
|
|
1107
|
+
["secrets", runtime.providers.secrets.provider]
|
|
1108
|
+
]) try {
|
|
1109
|
+
await createProviderReadinessProbe(component, {
|
|
1110
|
+
profile: runtime.profile,
|
|
1111
|
+
provider
|
|
1112
|
+
}, { sourceRoot: context.sourceRoot })();
|
|
1113
|
+
} catch {
|
|
1114
|
+
findings.push({
|
|
1115
|
+
code: "provider-not-configured",
|
|
1116
|
+
component,
|
|
1117
|
+
severity: "error",
|
|
1118
|
+
message: `The ${component} provider is not ready.`,
|
|
1119
|
+
recovery: `Configure and verify the installed ${component} provider readiness module.`
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
try {
|
|
1123
|
+
({paths} = runtimeEnvironment(context, runtime));
|
|
1124
|
+
statePath = resolveApplicationStateRoot({
|
|
1125
|
+
appId: context.appId,
|
|
1126
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
1127
|
+
sourceRoot: context.sourceRoot
|
|
1128
|
+
});
|
|
1129
|
+
accessSync(nearestExistingAncestor(statePath), constants.W_OK);
|
|
1130
|
+
if (runtime.profile === "local") paths = await context.deps.runtime.validateLocalDatabaseStorage({
|
|
1131
|
+
appId: context.appId,
|
|
1132
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
1133
|
+
sourceRoot: context.sourceRoot
|
|
1134
|
+
});
|
|
1135
|
+
canInspectMigrations = true;
|
|
1136
|
+
const host = process.env.HOST || (runtime.profile === "local" ? "127.0.0.1" : "0.0.0.0");
|
|
1137
|
+
if (runtime.profile === "local" && host !== "127.0.0.1" && host !== "::1") findings.push({
|
|
1138
|
+
code: "unsafe-local-bind",
|
|
1139
|
+
severity: "error",
|
|
1140
|
+
message: "Local owner bootstrap may only bind to a loopback address.",
|
|
1141
|
+
recovery: "Unset HOST or set HOST=127.0.0.1."
|
|
1142
|
+
});
|
|
1143
|
+
if (paths?.root) accessSync(nearestExistingAncestor(paths.root), constants.W_OK);
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
findings.push({
|
|
1146
|
+
code: "runtime-path-unavailable",
|
|
1147
|
+
severity: "error",
|
|
1148
|
+
message: "A required runtime path or provider configuration is unavailable.",
|
|
1149
|
+
recovery: error instanceof Error ? error.message : "Configure a writable runtime data path."
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
if (canInspectMigrations) try {
|
|
1153
|
+
const status = context.deps.runSmrt(["db:status", "--json"], {
|
|
1154
|
+
env: runtimeEnvironment(context, runtime).env,
|
|
1155
|
+
capture: true,
|
|
1156
|
+
allowFailure: true,
|
|
1157
|
+
label: "smrt db:status --json"
|
|
1158
|
+
});
|
|
1159
|
+
if (status.status !== 0) findings.push({
|
|
1160
|
+
code: "migration-status-failed",
|
|
1161
|
+
severity: "error",
|
|
1162
|
+
message: "Database migration status could not be verified.",
|
|
1163
|
+
recovery: "Run pnpm app:setup and inspect the private migration logs."
|
|
1164
|
+
});
|
|
1165
|
+
else {
|
|
1166
|
+
const migrationStatus = JSON.parse(status.stdout || "{}");
|
|
1167
|
+
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({
|
|
1168
|
+
code: "migration-required",
|
|
1169
|
+
severity: "error",
|
|
1170
|
+
message: "Database migrations are pending or failed.",
|
|
1171
|
+
recovery: "Run pnpm app:setup, then rerun pnpm app:doctor."
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
} catch {
|
|
1175
|
+
findings.push({
|
|
1176
|
+
code: "migration-status-failed",
|
|
1177
|
+
severity: "error",
|
|
1178
|
+
message: "Database migration status could not be parsed.",
|
|
1179
|
+
recovery: "Run pnpm app:setup and inspect the private migration logs."
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
const report = {
|
|
1184
|
+
schemaVersion: 1,
|
|
1185
|
+
status: findings.some((finding) => finding.severity === "error") ? "error" : "ready",
|
|
1186
|
+
profile: runtime?.profile || null,
|
|
1187
|
+
capabilities: runtime?.capabilities || null,
|
|
1188
|
+
paths: paths || statePath ? {
|
|
1189
|
+
root: paths?.root || null,
|
|
1190
|
+
database: paths?.database || null,
|
|
1191
|
+
assets: paths?.assets || null,
|
|
1192
|
+
state: statePath
|
|
1193
|
+
} : null,
|
|
1194
|
+
findings,
|
|
1195
|
+
secretValuesIncluded: false
|
|
1196
|
+
};
|
|
1197
|
+
printJson$1(context, report, true);
|
|
1198
|
+
return report.status === "error" ? 1 : 0;
|
|
1199
|
+
}
|
|
1200
|
+
async function backup(context, args) {
|
|
1201
|
+
if ((await context.deps.resolveRuntime(context.sourceRoot)).profile !== "local") throw new Error("This scaffold delegates deployed backups to the selected operator or managed provider.");
|
|
1202
|
+
const explicitDestination = args[0] ? assertExternalArtifactPath({
|
|
1203
|
+
sourceRoot: context.sourceRoot,
|
|
1204
|
+
path: resolve(args[0]),
|
|
1205
|
+
label: "Backup destination"
|
|
1206
|
+
}) : null;
|
|
1207
|
+
const paths = await context.deps.runtime.validateLocalDatabaseStorage({
|
|
1208
|
+
appId: context.appId,
|
|
1209
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
1210
|
+
sourceRoot: context.sourceRoot
|
|
1211
|
+
});
|
|
1212
|
+
await withOperationLock(preparedStateRoot(context), "backup", async (lock) => {
|
|
1213
|
+
const operatorLease = acquireWriterLease(preparedStateRoot(context), { operationInstance: lock.instance });
|
|
1214
|
+
try {
|
|
1215
|
+
const destination = assertExternalArtifactPath({
|
|
1216
|
+
sourceRoot: context.sourceRoot,
|
|
1217
|
+
path: explicitDestination || join(dirname(paths.root), "backups", `${context.appId}-${(/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-")}`),
|
|
1218
|
+
label: "Backup destination"
|
|
1219
|
+
});
|
|
1220
|
+
const storageRelative = relative(paths.root, destination);
|
|
1221
|
+
if (storageRelative === "" || !isAbsolute(storageRelative) && storageRelative !== ".." && !storageRelative.startsWith(`..${sep}`)) throw new Error("Backup destination must remain outside local storage.");
|
|
1222
|
+
ensurePrivateDirectory(dirname(destination));
|
|
1223
|
+
try {
|
|
1224
|
+
mkdirSync(destination, { mode: 448 });
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
if (error.code !== "EEXIST") throw error;
|
|
1227
|
+
throw new Error(`Backup destination already exists: ${destination}`);
|
|
1228
|
+
}
|
|
1229
|
+
try {
|
|
1230
|
+
for (const entry of readdirSync(paths.root)) cpSync(join(paths.root, entry), join(destination, entry), {
|
|
1231
|
+
recursive: true,
|
|
1232
|
+
force: false,
|
|
1233
|
+
errorOnExist: true
|
|
1234
|
+
});
|
|
1235
|
+
chmodSync(destination, 448);
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
throw new Error(`Backup copy failed; destination preserved at ${destination}`, { cause: error });
|
|
1238
|
+
}
|
|
1239
|
+
printJson$1(context, {
|
|
1240
|
+
schemaVersion: 1,
|
|
1241
|
+
status: "backed-up",
|
|
1242
|
+
destination
|
|
1243
|
+
});
|
|
1244
|
+
} finally {
|
|
1245
|
+
operatorLease.release();
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* The application's own adapter at `scripts/smrt-portability.mjs` keeps
|
|
1251
|
+
* working as the documented extension point; otherwise the built-in adapter
|
|
1252
|
+
* is used.
|
|
1253
|
+
*/
|
|
1254
|
+
async function loadPortabilityAdapter(context) {
|
|
1255
|
+
const override = join(context.sourceRoot, "scripts", "smrt-portability.mjs");
|
|
1256
|
+
if (existsSync(override)) return await import(pathToFileURL(override).href);
|
|
1257
|
+
return import("./portability-CES1YC2m.js").then((n) => n.s);
|
|
1258
|
+
}
|
|
1259
|
+
async function portability(context, operation, args, operationLock) {
|
|
1260
|
+
const adapter = await loadPortabilityAdapter(context);
|
|
1261
|
+
const runtime = await context.deps.resolveRuntime(context.sourceRoot);
|
|
1262
|
+
const environment = runtimeEnvironment(context, runtime);
|
|
1263
|
+
const requestedPath = args[0] ? resolve(args[0]) : void 0;
|
|
1264
|
+
const artifactPath = requestedPath ? assertExternalArtifactPath({
|
|
1265
|
+
sourceRoot: context.sourceRoot,
|
|
1266
|
+
path: requestedPath,
|
|
1267
|
+
label: operation === "export" ? "Export destination" : "Import source"
|
|
1268
|
+
}) : requestedPath;
|
|
1269
|
+
if (runtime.profile === "local") environment.paths = await context.deps.runtime.validateLocalDatabaseStorage({
|
|
1270
|
+
appId: context.appId,
|
|
1271
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
1272
|
+
sourceRoot: context.sourceRoot
|
|
1273
|
+
});
|
|
1274
|
+
const portabilityContext = {
|
|
1275
|
+
appId: context.appId,
|
|
1276
|
+
sourceRoot: context.sourceRoot,
|
|
1277
|
+
stateRoot: preparedStateRoot(context),
|
|
1278
|
+
runtime,
|
|
1279
|
+
env: environment.env,
|
|
1280
|
+
paths: environment.paths,
|
|
1281
|
+
assetRoot: environment.assetRoot
|
|
1282
|
+
};
|
|
1283
|
+
let operatorLease = null;
|
|
1284
|
+
if (operation === "import") {
|
|
1285
|
+
if (runtime.profile === "local") operatorLease = acquireWriterLease(preparedStateRoot(context), { operationInstance: operationLock?.instance });
|
|
1286
|
+
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.");
|
|
1287
|
+
}
|
|
1288
|
+
try {
|
|
1289
|
+
const result = await adapter[operation === "export" ? "exportApplication" : "importApplication"]({
|
|
1290
|
+
...portabilityContext,
|
|
1291
|
+
path: artifactPath
|
|
1292
|
+
});
|
|
1293
|
+
printJson$1(context, {
|
|
1294
|
+
schemaVersion: 1,
|
|
1295
|
+
status: `${operation}ed`,
|
|
1296
|
+
...result
|
|
1297
|
+
}, true);
|
|
1298
|
+
} finally {
|
|
1299
|
+
operatorLease?.release();
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Run one application operation. Resolves to the process exit code; throws
|
|
1304
|
+
* on failure (the caller renders the error envelope).
|
|
1305
|
+
*/
|
|
1306
|
+
async function runApplicationOperation(context, operation, args) {
|
|
1307
|
+
switch (operation) {
|
|
1308
|
+
case "install":
|
|
1309
|
+
await assertLocalOperation(context, "app:install");
|
|
1310
|
+
await withOperationLock(preparedStateRoot(context), "install", async (lock) => {
|
|
1311
|
+
const report = await setup(context, lock);
|
|
1312
|
+
await start(context, lock);
|
|
1313
|
+
const baseUrl = `http://127.0.0.1:${process.env.PORT || "5173"}/`;
|
|
1314
|
+
if (report.onboardingUrl) saveOnboardingLaunch(context, report.onboardingUrl);
|
|
1315
|
+
context.deps.openBrowser(report.onboardingUrl ? pathToFileURL(onboardingLaunchPath(context)).toString() : baseUrl);
|
|
1316
|
+
});
|
|
1317
|
+
return 0;
|
|
1318
|
+
case "setup":
|
|
1319
|
+
await withOperationLock(preparedStateRoot(context), operation, async (lock) => {
|
|
1320
|
+
printOnboardingUrl(context, (await setup(context, lock)).onboardingUrl);
|
|
1321
|
+
});
|
|
1322
|
+
return 0;
|
|
1323
|
+
case "recover":
|
|
1324
|
+
await assertLocalOperation(context, "app:recover");
|
|
1325
|
+
await withOperationLock(preparedStateRoot(context), operation, (lock) => recoverOnboarding(context, lock));
|
|
1326
|
+
return 0;
|
|
1327
|
+
case "start":
|
|
1328
|
+
await assertLocalOperation(context, "app:start");
|
|
1329
|
+
await withOperationLock(preparedStateRoot(context), operation, (lock) => start(context, lock));
|
|
1330
|
+
return 0;
|
|
1331
|
+
case "doctor": return doctor(context);
|
|
1332
|
+
case "open":
|
|
1333
|
+
await open(context);
|
|
1334
|
+
return 0;
|
|
1335
|
+
case "stop":
|
|
1336
|
+
await assertLocalOperation(context, "app:stop");
|
|
1337
|
+
await withOperationLock(preparedStateRoot(context), operation, () => stop(context));
|
|
1338
|
+
return 0;
|
|
1339
|
+
case "backup":
|
|
1340
|
+
await backup(context, args);
|
|
1341
|
+
return 0;
|
|
1342
|
+
case "export":
|
|
1343
|
+
case "import":
|
|
1344
|
+
await withOperationLock(preparedStateRoot(context), operation, (lock) => portability(context, operation, args, lock));
|
|
1345
|
+
return 0;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
//#endregion
|
|
1349
|
+
//#region src/app/tokens.ts
|
|
1350
|
+
/**
|
|
1351
|
+
* `smrt app token` — owner-minted bearer tokens for local MCP clients.
|
|
1352
|
+
*
|
|
1353
|
+
* ```
|
|
1354
|
+
* smrt app token --scopes notes.read [--expires 30d] [--label "Claude Desktop"]
|
|
1355
|
+
* smrt app token list
|
|
1356
|
+
* smrt app token revoke <id>
|
|
1357
|
+
* ```
|
|
1358
|
+
*
|
|
1359
|
+
* Local profile only. The token is printed once, in the `issued` JSON
|
|
1360
|
+
* document on stdout, and is otherwise never written anywhere: the runtime
|
|
1361
|
+
* stores only its HMAC. `list` and `revoke` output carries no token values.
|
|
1362
|
+
*/
|
|
1363
|
+
/** Usage text for `smrt app token`. */
|
|
1364
|
+
var TOKEN_USAGE = "Usage: smrt app token [create] --scopes <scope[,scope...]> [--expires <n>s|m|h|d] [--label <text>] | token list | token revoke <id>";
|
|
1365
|
+
var UNIT_SECONDS = {
|
|
1366
|
+
s: 1,
|
|
1367
|
+
m: 60,
|
|
1368
|
+
h: 3600,
|
|
1369
|
+
d: 1440 * 60
|
|
1370
|
+
};
|
|
1371
|
+
/** Parse `30d`, `12h`, `90m`, `3600s` or bare seconds. */
|
|
1372
|
+
function parseTokenLifetime(value) {
|
|
1373
|
+
const match = /^([1-9][0-9]{0,8})([smhd]?)$/u.exec(value.trim());
|
|
1374
|
+
if (!match) throw new Error(`Invalid --expires value. ${TOKEN_USAGE}`);
|
|
1375
|
+
return Number(match[1]) * UNIT_SECONDS[match[2] || "s"];
|
|
1376
|
+
}
|
|
1377
|
+
function parseCreateArgs(args) {
|
|
1378
|
+
const scopes = [];
|
|
1379
|
+
let expiresInSeconds;
|
|
1380
|
+
let label;
|
|
1381
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1382
|
+
const arg = args[index];
|
|
1383
|
+
const equals = arg.indexOf("=");
|
|
1384
|
+
const name = equals > 0 ? arg.slice(0, equals) : arg;
|
|
1385
|
+
if (![
|
|
1386
|
+
"--scopes",
|
|
1387
|
+
"--scope",
|
|
1388
|
+
"--expires",
|
|
1389
|
+
"--label"
|
|
1390
|
+
].includes(name)) throw new Error(`Unknown token option: ${name}. ${TOKEN_USAGE}`);
|
|
1391
|
+
const value = equals > 0 ? arg.slice(equals + 1) : args[++index];
|
|
1392
|
+
if (value === void 0 || equals < 0 && value.startsWith("--")) throw new Error(`Token option ${name} requires a value. ${TOKEN_USAGE}`);
|
|
1393
|
+
if (name === "--scopes" || name === "--scope") scopes.push(...value.split(/[\s,]+/u).filter(Boolean));
|
|
1394
|
+
else if (name === "--expires") {
|
|
1395
|
+
if (expiresInSeconds !== void 0) throw new Error("Token option --expires was provided more than once.");
|
|
1396
|
+
expiresInSeconds = parseTokenLifetime(value);
|
|
1397
|
+
} else {
|
|
1398
|
+
if (label !== void 0) throw new Error("Token option --label was provided more than once.");
|
|
1399
|
+
label = value;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
if (scopes.length === 0) throw new Error(`At least one scope is required. ${TOKEN_USAGE}`);
|
|
1403
|
+
return {
|
|
1404
|
+
scopes,
|
|
1405
|
+
expiresInSeconds,
|
|
1406
|
+
label
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
function printJson(context, value) {
|
|
1410
|
+
context.io.stdout(`${JSON.stringify(value, null, 2)}\n`);
|
|
1411
|
+
}
|
|
1412
|
+
/** Run `smrt app token <args…>` and resolve to the exit code. */
|
|
1413
|
+
async function runTokenOperation(context, args) {
|
|
1414
|
+
const [first, ...rest] = args;
|
|
1415
|
+
if (first === "help" || first === "--help" || first === "-h") {
|
|
1416
|
+
context.io.stdout(`${TOKEN_USAGE}\n`);
|
|
1417
|
+
return 0;
|
|
1418
|
+
}
|
|
1419
|
+
const subcommand = first === void 0 || first.startsWith("--") ? "create" : first;
|
|
1420
|
+
const subArgs = subcommand === first ? rest : args;
|
|
1421
|
+
if (![
|
|
1422
|
+
"create",
|
|
1423
|
+
"list",
|
|
1424
|
+
"revoke"
|
|
1425
|
+
].includes(subcommand)) throw new Error(`Unknown token operation: ${subcommand}. ${TOKEN_USAGE}`);
|
|
1426
|
+
const create = subcommand === "create" ? parseCreateArgs(subArgs) : null;
|
|
1427
|
+
if (subcommand === "list" && subArgs.length > 0) throw new Error(`token list takes no arguments. ${TOKEN_USAGE}`);
|
|
1428
|
+
if (subcommand === "revoke" && subArgs.length !== 1) throw new Error(`token revoke takes exactly one token id. ${TOKEN_USAGE}`);
|
|
1429
|
+
await assertLocalOperation(context, "app:token");
|
|
1430
|
+
const open = context.deps.runtime.openLocalMcpTokenStore ?? openLocalMcpTokenStore;
|
|
1431
|
+
const withStore = async (fn) => {
|
|
1432
|
+
const store = await open({
|
|
1433
|
+
appId: context.appId,
|
|
1434
|
+
dataDirectory: process.env.SMRT_DATA_DIR,
|
|
1435
|
+
sourceRoot: context.sourceRoot
|
|
1436
|
+
});
|
|
1437
|
+
try {
|
|
1438
|
+
return await fn(store);
|
|
1439
|
+
} finally {
|
|
1440
|
+
await store.close();
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
if (subcommand === "list") {
|
|
1444
|
+
printJson(context, {
|
|
1445
|
+
schemaVersion: 1,
|
|
1446
|
+
status: "ok",
|
|
1447
|
+
tokens: await withStore((store) => store.list()),
|
|
1448
|
+
secretValuesIncluded: false
|
|
1449
|
+
});
|
|
1450
|
+
return 0;
|
|
1451
|
+
}
|
|
1452
|
+
return withOperationLock(preparedStateRoot(context), `token-${subcommand}`, async () => {
|
|
1453
|
+
if (create) {
|
|
1454
|
+
const issued = await withStore((store) => store.issue(create));
|
|
1455
|
+
printJson(context, {
|
|
1456
|
+
schemaVersion: 1,
|
|
1457
|
+
status: "issued",
|
|
1458
|
+
id: issued.id,
|
|
1459
|
+
token: issued.token,
|
|
1460
|
+
scopes: issued.scopes,
|
|
1461
|
+
label: issued.label,
|
|
1462
|
+
createdAt: issued.createdAt,
|
|
1463
|
+
expiresAt: issued.expiresAt,
|
|
1464
|
+
notice: "This token is shown once and only its hash is stored. Put it in your MCP client configuration now; revoke it with smrt app token revoke <id>.",
|
|
1465
|
+
secretValuesIncluded: true
|
|
1466
|
+
});
|
|
1467
|
+
return 0;
|
|
1468
|
+
}
|
|
1469
|
+
const id = subArgs[0];
|
|
1470
|
+
printJson(context, {
|
|
1471
|
+
schemaVersion: 1,
|
|
1472
|
+
status: await withStore((store) => store.revoke(id)),
|
|
1473
|
+
id,
|
|
1474
|
+
secretValuesIncluded: false
|
|
1475
|
+
});
|
|
1476
|
+
return 0;
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
//#endregion
|
|
1480
|
+
//#region src/app/cli.ts
|
|
1481
|
+
/**
|
|
1482
|
+
* `smrt app <operation>` dispatcher.
|
|
1483
|
+
*
|
|
1484
|
+
* Every operation writes machine-readable JSON to stdout and, on failure,
|
|
1485
|
+
* one secret-free JSON envelope to stderr with exit code 1 — the contract
|
|
1486
|
+
* the template's `scripts/smrt-app.mjs` established.
|
|
1487
|
+
*/
|
|
1488
|
+
var cli_exports = /* @__PURE__ */ __exportAll({
|
|
1489
|
+
APP_COMMANDS: () => APP_COMMANDS,
|
|
1490
|
+
APP_RECOVERY: () => APP_RECOVERY,
|
|
1491
|
+
errorEnvelope: () => errorEnvelope,
|
|
1492
|
+
renderAppHelp: () => renderAppHelp,
|
|
1493
|
+
runAppCommand: () => runAppCommand
|
|
1494
|
+
});
|
|
1495
|
+
/** Recovery line carried by every error envelope. */
|
|
1496
|
+
var APP_RECOVERY = "Run pnpm app:doctor and follow its recovery instructions.";
|
|
1497
|
+
/** Every `smrt app` command with a one-line description (for help output). */
|
|
1498
|
+
var APP_COMMANDS = Object.freeze({
|
|
1499
|
+
install: "Local: set up, start, and open owner onboarding in one step",
|
|
1500
|
+
setup: "Build, apply migrations explicitly, and prepare owner onboarding",
|
|
1501
|
+
recover: "Local: issue a fresh single-use owner onboarding invitation",
|
|
1502
|
+
start: "Local: start the production build on loopback and prove readiness",
|
|
1503
|
+
stop: "Local: stop the managed application process",
|
|
1504
|
+
doctor: "Report runtime, storage, provider, and migration findings (default)",
|
|
1505
|
+
open: "Open the application or pending owner onboarding in a browser",
|
|
1506
|
+
"backup [destination]": "Local: copy the private data root to a new directory",
|
|
1507
|
+
"export [path]": "Write a logical, asset-aware export bundle",
|
|
1508
|
+
"import <path>": "Import a logical export bundle into an empty application",
|
|
1509
|
+
migrate: "Prepare storage custody and run smrt db:migrate under the lock",
|
|
1510
|
+
"token [list|revoke <id>] [--scopes ...]": "Local: issue, list, or revoke a scoped bearer token for a local MCP client",
|
|
1511
|
+
"worker [task|schedule]": "Deployed: run the task or schedule worker",
|
|
1512
|
+
"dev [...vite args]": "Run the application's Vite dev server",
|
|
1513
|
+
"build [...vite args]": "Validate ./mcp-apps when present, then run the Vite build",
|
|
1514
|
+
"vite [...args]": "Run the application's installed Vite CLI with args"
|
|
1515
|
+
});
|
|
1516
|
+
/** Build the stderr envelope for `error`, redacting secret material. */
|
|
1517
|
+
function errorEnvelope(error) {
|
|
1518
|
+
const runtimeCode = error instanceof Error && error.name === "LocalRuntimeError" && typeof error.code === "string" ? error.code : void 0;
|
|
1519
|
+
return {
|
|
1520
|
+
schemaVersion: 1,
|
|
1521
|
+
status: "error",
|
|
1522
|
+
code: "operation-failed",
|
|
1523
|
+
...runtimeCode ? { runtimeCode } : {},
|
|
1524
|
+
message: redactSecrets(error instanceof Error ? error.message : "Application operation failed."),
|
|
1525
|
+
...error instanceof ApplicationStartError ? {
|
|
1526
|
+
output: boundedTail(redactSecrets(error.output, process.env, { strict: true }), START_OUTPUT_TAIL_BYTES),
|
|
1527
|
+
logFile: error.logFile
|
|
1528
|
+
} : {},
|
|
1529
|
+
recovery: APP_RECOVERY,
|
|
1530
|
+
secretValuesIncluded: false
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
/** Render `smrt app` usage. */
|
|
1534
|
+
function renderAppHelp() {
|
|
1535
|
+
const width = Math.max(...Object.keys(APP_COMMANDS).map((key) => key.length));
|
|
1536
|
+
return `Usage: smrt app <operation> [args]\n\nOperations:\n${Object.entries(APP_COMMANDS).map(([name, description]) => ` ${name.padEnd(width)} ${description}`).join("\n")}\n`;
|
|
1537
|
+
}
|
|
1538
|
+
var ENV_FILE_OPERATIONS = /* @__PURE__ */ new Set([
|
|
1539
|
+
...APP_OPERATIONS,
|
|
1540
|
+
"migrate",
|
|
1541
|
+
"token",
|
|
1542
|
+
"dev",
|
|
1543
|
+
"build",
|
|
1544
|
+
"vite"
|
|
1545
|
+
]);
|
|
1546
|
+
/**
|
|
1547
|
+
* Run `smrt app <argv…>` and resolve to the process exit code.
|
|
1548
|
+
*
|
|
1549
|
+
* `argv` is everything after `app`. A leading `--` before operation
|
|
1550
|
+
* arguments is ignored, matching `pnpm app:import -- <path>`.
|
|
1551
|
+
*/
|
|
1552
|
+
async function runAppCommand(argv, options = {}) {
|
|
1553
|
+
const io = {
|
|
1554
|
+
stdout: options.io?.stdout ?? ((text) => void process.stdout.write(text)),
|
|
1555
|
+
stderr: options.io?.stderr ?? ((text) => void process.stderr.write(text)),
|
|
1556
|
+
operatorTerminal: options.io ? options.io.operatorTerminal : process.stderr.isTTY && process.stdout.isTTY ? (text) => void process.stderr.write(text) : void 0
|
|
1557
|
+
};
|
|
1558
|
+
const operation = argv[0] || "doctor";
|
|
1559
|
+
const rawArgs = argv.slice(1);
|
|
1560
|
+
const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
|
|
1561
|
+
if (operation === "help" || operation === "--help" || operation === "-h") {
|
|
1562
|
+
io.stdout(renderAppHelp());
|
|
1563
|
+
return 0;
|
|
1564
|
+
}
|
|
1565
|
+
try {
|
|
1566
|
+
const sourceRoot = resolve(options.cwd ?? process.cwd());
|
|
1567
|
+
if (ENV_FILE_OPERATIONS.has(operation)) loadSourceEnvironment(sourceRoot);
|
|
1568
|
+
if (operation === "dev" || operation === "build" || operation === "vite") {
|
|
1569
|
+
const context = {
|
|
1570
|
+
sourceRoot,
|
|
1571
|
+
appId: "",
|
|
1572
|
+
io,
|
|
1573
|
+
deps: {}
|
|
1574
|
+
};
|
|
1575
|
+
if (operation === "build") {
|
|
1576
|
+
const status = await validateMcpAppsIfPresent(context);
|
|
1577
|
+
if (status !== 0) return status;
|
|
1578
|
+
}
|
|
1579
|
+
return launchVite(context, operation === "vite" ? args : operation === "dev" ? ["dev", ...devServerArguments(args)] : [operation, ...args]);
|
|
1580
|
+
}
|
|
1581
|
+
const deps = {
|
|
1582
|
+
...await defaultDependencies(sourceRoot),
|
|
1583
|
+
...options.dependencies
|
|
1584
|
+
};
|
|
1585
|
+
const context = {
|
|
1586
|
+
sourceRoot,
|
|
1587
|
+
appId: resolveContextApplicationId(sourceRoot),
|
|
1588
|
+
io,
|
|
1589
|
+
deps
|
|
1590
|
+
};
|
|
1591
|
+
if (operation === "migrate") return await prepareMigration(context);
|
|
1592
|
+
if (operation === "worker") return await runWorker(context, args);
|
|
1593
|
+
if (operation === "token") return await runTokenOperation(context, args);
|
|
1594
|
+
if (!APP_OPERATIONS.includes(operation)) throw new Error(`Unknown app operation: ${operation}`);
|
|
1595
|
+
return await runApplicationOperation(context, operation, args);
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
io.stderr(`${JSON.stringify(errorEnvelope(error))}\n`);
|
|
1598
|
+
return 1;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
//#endregion
|
|
1602
|
+
export { assertExternalArtifactPath as A, prepareMigration as C, cliExecutablePath as D, validateMcpAppsIfPresent as E, AppCommandError as F, ApplicationStartError as I, errorCode as L, resolveApplicationId as M, resolveApplicationStateRoot as N, runtimeEnvironment as O, runtimeConfigurationFingerprint as P, redactSecrets as R, launchVite as S, runWorker as T, sendTerminationSignal as _, renderAppHelp as a, ViteLauncherError as b, parseTokenLifetime as c, START_OUTPUT_TAIL_BYTES as d, runApplicationOperation as f, readOwnedProcess as g, matchesApplicationProcess as h, errorEnvelope as i, prepareApplicationStateRoot as j, webLauncherPath as k, runTokenOperation as l, checkOwnedProcess as m, APP_RECOVERY as n, runAppCommand as o, WEB_LAUNCHER_NAME as p, cli_exports as r, TOKEN_USAGE as s, APP_COMMANDS as t, APP_OPERATIONS as u, verifyOwnedProcess as v, resolveViteEntry as w, devServerArguments as x, writeProcessRecord as y };
|