@harperfast/harper 5.2.0-beta.1 → 5.2.0-beta.2
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/bin/cliOperations.ts +76 -12
- package/bin/run.ts +10 -0
- package/bin/status.ts +1 -1
- package/components/Application.ts +146 -83
- package/components/Scope.ts +4 -0
- package/components/componentLoader.ts +7 -0
- package/components/operations.js +21 -1
- package/config/configUtils.ts +139 -5
- package/config-app.schema.json +70 -0
- package/dist/bin/cliOperations.js +76 -12
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/run.js +9 -0
- package/dist/bin/run.js.map +1 -1
- package/dist/bin/status.js +1 -1
- package/dist/bin/status.js.map +1 -1
- package/dist/components/Application.d.ts +12 -5
- package/dist/components/Application.js +121 -60
- package/dist/components/Application.js.map +1 -1
- package/dist/components/Scope.d.ts +1 -0
- package/dist/components/Scope.js +4 -0
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +7 -0
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/operations.js +20 -1
- package/dist/components/operations.js.map +1 -1
- package/dist/config/configUtils.d.ts +31 -0
- package/dist/config/configUtils.js +127 -5
- package/dist/config/configUtils.js.map +1 -1
- package/dist/resources/DatabaseTransaction.d.ts +12 -0
- package/dist/resources/DatabaseTransaction.js +97 -0
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/RequestTarget.js +13 -3
- package/dist/resources/RequestTarget.js.map +1 -1
- package/dist/resources/Resource.js +16 -0
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/Table.js +35 -2
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/metadata.d.ts +3 -0
- package/dist/resources/analytics/metadata.js +3 -0
- package/dist/resources/analytics/metadata.js.map +1 -1
- package/dist/resources/analytics/write.js +22 -0
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/defineResource.js +20 -7
- package/dist/resources/defineResource.js.map +1 -1
- package/dist/resources/jsResource.d.ts +24 -0
- package/dist/resources/jsResource.js +58 -2
- package/dist/resources/jsResource.js.map +1 -1
- package/dist/resources/openApi.js +45 -20
- package/dist/resources/openApi.js.map +1 -1
- package/dist/resources/scheduler/CronExpression.d.ts +71 -0
- package/dist/resources/scheduler/CronExpression.js +367 -0
- package/dist/resources/scheduler/CronExpression.js.map +1 -0
- package/dist/resources/scheduler/engine.d.ts +91 -0
- package/dist/resources/scheduler/engine.js +767 -0
- package/dist/resources/scheduler/engine.js.map +1 -0
- package/dist/resources/scheduler/scheduler.d.ts +33 -0
- package/dist/resources/scheduler/scheduler.js +200 -0
- package/dist/resources/scheduler/scheduler.js.map +1 -0
- package/dist/security/auth.js +1 -0
- package/dist/security/auth.js.map +1 -1
- package/dist/security/jsLoader.js +8 -0
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/security/keys.d.ts +32 -0
- package/dist/security/keys.js +147 -0
- package/dist/security/keys.js.map +1 -1
- package/dist/server/REST.js +67 -1
- package/dist/server/REST.js.map +1 -1
- package/dist/server/Server.d.ts +6 -0
- package/dist/server/Server.js.map +1 -1
- package/dist/server/http.d.ts +2 -0
- package/dist/server/http.js +139 -14
- package/dist/server/http.js.map +1 -1
- package/dist/server/operationsServer.js +3 -3
- package/dist/server/operationsServer.js.map +1 -1
- package/dist/server/serverHelpers/progressEmitter.js +5 -1
- package/dist/server/serverHelpers/progressEmitter.js.map +1 -1
- package/dist/server/threads/threadServer.js +9 -5
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/utility/common_utils.js +25 -0
- package/dist/utility/common_utils.js.map +1 -1
- package/dist/utility/install/installer.d.ts +9 -1
- package/dist/utility/install/installer.js +21 -0
- package/dist/utility/install/installer.js.map +1 -1
- package/dist/validation/configValidator.js +3 -0
- package/dist/validation/configValidator.js.map +1 -1
- package/npm-shrinkwrap.json +272 -230
- package/package.json +3 -3
- package/resources/DESIGN.md +1 -1
- package/resources/DatabaseTransaction.ts +95 -0
- package/resources/RequestTarget.ts +12 -3
- package/resources/Resource.ts +18 -0
- package/resources/Table.ts +34 -3
- package/resources/analytics/metadata.ts +3 -0
- package/resources/analytics/write.ts +23 -0
- package/resources/defineResource.ts +17 -4
- package/resources/jsResource.ts +61 -2
- package/resources/openApi.ts +44 -19
- package/resources/scheduler/CronExpression.ts +394 -0
- package/resources/scheduler/engine.ts +812 -0
- package/resources/scheduler/scheduler.ts +236 -0
- package/security/auth.ts +1 -0
- package/security/jsLoader.ts +8 -0
- package/security/keys.ts +152 -0
- package/server/REST.ts +70 -1
- package/server/Server.ts +6 -0
- package/server/http.ts +122 -15
- package/server/operationsServer.ts +5 -3
- package/server/serverHelpers/progressEmitter.ts +5 -1
- package/server/threads/threadServer.js +9 -5
- package/studio/web/assets/{Chat-BZks8dVF.js → Chat-DHP4XpID.js} +2 -2
- package/studio/web/assets/{Chat-BZks8dVF.js.map → Chat-DHP4XpID.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-Dic8paVO.js → FloatingChat-CJ7PssCv.js} +4 -4
- package/studio/web/assets/{FloatingChat-Dic8paVO.js.map → FloatingChat-CJ7PssCv.js.map} +1 -1
- package/studio/web/assets/{applications-uOXkeUIN.js → applications-DxXiGpsR.js} +2 -2
- package/studio/web/assets/{applications-uOXkeUIN.js.map → applications-DxXiGpsR.js.map} +1 -1
- package/studio/web/assets/{index-i-2wrKhv.js → index-BdbBanDP.js} +6 -6
- package/studio/web/assets/{index-i-2wrKhv.js.map → index-BdbBanDP.js.map} +1 -1
- package/studio/web/assets/{index.lazy-Csk8eCoB.js → index.lazy-B2eH28zD.js} +4 -4
- package/studio/web/assets/{index.lazy-Csk8eCoB.js.map → index.lazy-B2eH28zD.js.map} +1 -1
- package/studio/web/assets/{profile-Sb3mGDl6.js → profile-DK5hgucv.js} +2 -2
- package/studio/web/assets/{profile-Sb3mGDl6.js.map → profile-DK5hgucv.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-BgZcaPJ2.js → setComponentFile-BVDWRYxx.js} +2 -2
- package/studio/web/assets/{setComponentFile-BgZcaPJ2.js.map → setComponentFile-BVDWRYxx.js.map} +1 -1
- package/studio/web/assets/{setup-DKtlLgmT.js → setup-DJ9BInoK.js} +2 -2
- package/studio/web/assets/{setup-DKtlLgmT.js.map → setup-DJ9BInoK.js.map} +1 -1
- package/studio/web/assets/{status-B45iLeug.js → status-B_qzmgfD.js} +2 -2
- package/studio/web/assets/{status-B45iLeug.js.map → status-B_qzmgfD.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-Csu4026e.js → swagger-ui-react-DOL5jCqg.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-Csu4026e.js.map → swagger-ui-react-DOL5jCqg.js.map} +1 -1
- package/studio/web/assets/{tsMode-DVgxUr_l.js → tsMode-DpxUxfTW.js} +2 -2
- package/studio/web/assets/{tsMode-DVgxUr_l.js.map → tsMode-DpxUxfTW.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-yfDQMV1f.js → useEntityRestURL-CU_lY6XW.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-yfDQMV1f.js.map → useEntityRestURL-CU_lY6XW.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/common_utils.ts +26 -0
- package/utility/install/installer.ts +26 -1
- package/validation/configValidator.ts +3 -0
package/bin/cliOperations.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { httpRequest } from '../utility/common_utils.ts';
|
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import * as fs from 'fs-extra';
|
|
11
11
|
import * as YAML from 'yaml';
|
|
12
|
+
import { Readable } from 'node:stream';
|
|
12
13
|
import { streamPackagedDirectory, packageDirectory, scanPackageDirectory } from '../components/packageComponent.ts';
|
|
13
14
|
import { encode as encodeCbor } from 'cbor-x';
|
|
14
15
|
import { buildMultipartBody } from './multipartBuilder.ts';
|
|
@@ -19,6 +20,12 @@ import { initConfig, getConfigPath } from '../config/configUtils.ts';
|
|
|
19
20
|
|
|
20
21
|
const OP_ALIASES = { deploy: 'deploy_component', package: 'package_component' };
|
|
21
22
|
|
|
23
|
+
// Shown for any local-instance connection failure (missing pid, missing/stale domain
|
|
24
|
+
// socket, or a refused/ENOENT connect against it) — they're all the same user-facing
|
|
25
|
+
// scenario: Harper isn't running. Remote-target failures keep the detailed error instead,
|
|
26
|
+
// since there's no single "just start it" fix for those.
|
|
27
|
+
const LOCAL_NOT_RUNNING_MESSAGE = 'Harper is not running. Use `harperdb run` (or `harperdb start`) to start it.';
|
|
28
|
+
|
|
22
29
|
// Operations whose responses should be consumed as text/event-stream so live phase events
|
|
23
30
|
// (prepare, load, replicate, restart) render as they happen instead of after the whole
|
|
24
31
|
// deploy completes. Add an operation here only after wiring its server-side
|
|
@@ -49,6 +56,34 @@ const TRANSPORT_ONLY_FIELDS = new Set([
|
|
|
49
56
|
const STREAMING_DEPLOY_MIN_MAJOR = 5;
|
|
50
57
|
const STREAMING_DEPLOY_MIN_MINOR = 1;
|
|
51
58
|
|
|
59
|
+
// Idle-socket timeout for CLI Op-API requests: no traffic (in either direction) for this long
|
|
60
|
+
// means the target is unreachable or wedged. Resets on any activity, so a slow-but-active
|
|
61
|
+
// upload/deploy is unaffected — only a fully silent connection trips it. Overridable for
|
|
62
|
+
// operations against known-slow targets.
|
|
63
|
+
//
|
|
64
|
+
// SSE-based operations (see SSE_OPERATIONS above) get a much longer default: a long-running
|
|
65
|
+
// deploy_component can go quiet between phase events (e.g. a slow replicate/load step) for well
|
|
66
|
+
// over a minute even though the connection is perfectly healthy, so the generic 60s default is
|
|
67
|
+
// too tight for this one. HARPER_CLI_TIMEOUT_MS/CLI_TIMEOUT_MS, when set, overrides BOTH
|
|
68
|
+
// defaults uniformly — it's a single "I know what timeout I want" escape hatch rather than two
|
|
69
|
+
// separate env vars to keep in sync.
|
|
70
|
+
const DEFAULT_CLI_OPERATION_TIMEOUT_MS = 60000;
|
|
71
|
+
const DEFAULT_SSE_OPERATION_TIMEOUT_MS = 600000; // 10 minutes
|
|
72
|
+
// Largest delay Node's setTimeout accepts; a larger value is silently coerced and fires in
|
|
73
|
+
// ~1ms instead of the intended delay, so out-of-range input is treated the same as any other
|
|
74
|
+
// invalid input below (falls back to DEFAULT_CLI_OPERATION_TIMEOUT_MS) rather than passed through.
|
|
75
|
+
const MAX_CLI_OPERATION_TIMEOUT_MS = 2147483647; // 2^31 - 1
|
|
76
|
+
const RAW_CLI_OPERATION_TIMEOUT = (process.env.HARPER_CLI_TIMEOUT_MS || process.env.CLI_TIMEOUT_MS)?.trim();
|
|
77
|
+
const PARSED_CLI_OPERATION_TIMEOUT = RAW_CLI_OPERATION_TIMEOUT ? Number(RAW_CLI_OPERATION_TIMEOUT) : NaN;
|
|
78
|
+
const CLI_OPERATION_TIMEOUT_OVERRIDE_MS =
|
|
79
|
+
Number.isInteger(PARSED_CLI_OPERATION_TIMEOUT) &&
|
|
80
|
+
PARSED_CLI_OPERATION_TIMEOUT >= 0 &&
|
|
81
|
+
PARSED_CLI_OPERATION_TIMEOUT <= MAX_CLI_OPERATION_TIMEOUT_MS
|
|
82
|
+
? PARSED_CLI_OPERATION_TIMEOUT
|
|
83
|
+
: undefined;
|
|
84
|
+
const CLI_OPERATION_TIMEOUT_MS = CLI_OPERATION_TIMEOUT_OVERRIDE_MS ?? DEFAULT_CLI_OPERATION_TIMEOUT_MS;
|
|
85
|
+
const SSE_OPERATION_TIMEOUT_MS = CLI_OPERATION_TIMEOUT_OVERRIDE_MS ?? DEFAULT_SSE_OPERATION_TIMEOUT_MS;
|
|
86
|
+
|
|
52
87
|
/**
|
|
53
88
|
* Parses a Harper version string (e.g. "5.0.31", "5.1.0-beta.2") and reports whether the
|
|
54
89
|
* server is new enough to accept the multipart + SSE streaming deploy. Unparseable input
|
|
@@ -73,7 +108,11 @@ function versionSupportsStreamingDeploy(version: unknown): boolean {
|
|
|
73
108
|
*/
|
|
74
109
|
async function targetSupportsStreamingDeploy(options: any): Promise<boolean> {
|
|
75
110
|
try {
|
|
76
|
-
const probeOptions = {
|
|
111
|
+
const probeOptions = {
|
|
112
|
+
...options,
|
|
113
|
+
headers: { ...options.headers, Accept: 'application/json' },
|
|
114
|
+
timeout: CLI_OPERATION_TIMEOUT_MS,
|
|
115
|
+
};
|
|
77
116
|
delete probeOptions.streamResponse;
|
|
78
117
|
const response = await httpRequest(probeOptions, { operation: 'registration_info' });
|
|
79
118
|
if (response.statusCode !== 200 || !response.body) return true;
|
|
@@ -84,6 +123,21 @@ async function targetSupportsStreamingDeploy(options: any): Promise<boolean> {
|
|
|
84
123
|
}
|
|
85
124
|
}
|
|
86
125
|
|
|
126
|
+
// Wraps the local packaging stream so an fs error while tar'ing up the payload (e.g. a file
|
|
127
|
+
// vanishing after the pre-deploy scan, or a permissions failure reading the project tree)
|
|
128
|
+
// surfaces as a descriptive packaging error instead of a raw fs error code. Without this, an
|
|
129
|
+
// ENOENT from *packaging* is indistinguishable from an ENOENT/ECONNREFUSED connecting to the
|
|
130
|
+
// local domain socket, and the catch block below (which classifies purely on err.code) would
|
|
131
|
+
// misreport it as "Harper is not running" even though Harper is running fine. Mirrors the
|
|
132
|
+
// legacy deploy path's wrapping of packageDirectory() below.
|
|
133
|
+
async function* wrapPackagingStream(stream: Readable, projectPath: string): AsyncGenerator<Buffer> {
|
|
134
|
+
try {
|
|
135
|
+
for await (const chunk of stream) yield chunk as Buffer;
|
|
136
|
+
} catch (err: any) {
|
|
137
|
+
throw new Error(`Failed to package component directory '${projectPath}': ${err.message}`, { cause: err });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
87
141
|
// Build the JSON operation-field set from `req`, dropping the CLI's internal (`_`-prefixed)
|
|
88
142
|
// and transport-only fields so neither the CLI internals nor credentials leak into the
|
|
89
143
|
// request body. Shared by the multipart and legacy-JSON deploy body builders.
|
|
@@ -213,12 +267,12 @@ async function cliOperations(req: any, skipResponseLog = false) {
|
|
|
213
267
|
console.error('Connecting to local Harper instance');
|
|
214
268
|
initConfig();
|
|
215
269
|
if (!getHdbPid()) {
|
|
216
|
-
console.error(
|
|
270
|
+
console.error(LOCAL_NOT_RUNNING_MESSAGE);
|
|
217
271
|
process.exit(1);
|
|
218
272
|
}
|
|
219
273
|
|
|
220
274
|
if (!fs.existsSync(getConfigPath(terms.CONFIG_PARAMS.OPERATIONSAPI_NETWORK_DOMAINSOCKET))) {
|
|
221
|
-
console.error(
|
|
275
|
+
console.error(LOCAL_NOT_RUNNING_MESSAGE);
|
|
222
276
|
process.exit(1);
|
|
223
277
|
}
|
|
224
278
|
}
|
|
@@ -230,6 +284,7 @@ async function cliOperations(req: any, skipResponseLog = false) {
|
|
|
230
284
|
};
|
|
231
285
|
options.method = 'POST';
|
|
232
286
|
options.headers = { 'Content-Type': 'application/json' };
|
|
287
|
+
options.timeout = SSE_OPERATIONS.has(req.operation) ? SSE_OPERATION_TIMEOUT_MS : CLI_OPERATION_TIMEOUT_MS;
|
|
233
288
|
if (target?.username) {
|
|
234
289
|
options.headers.Authorization = `Basic ${Buffer.from(`${target.username}:${target.password}`).toString('base64')}`;
|
|
235
290
|
} else if (allCredentials) {
|
|
@@ -244,7 +299,7 @@ async function cliOperations(req: any, skipResponseLog = false) {
|
|
|
244
299
|
if (tokens.refresh_token && isJWTExpired(tokens.operation_token)) {
|
|
245
300
|
console.error('Operation token expired, attempting to refresh...');
|
|
246
301
|
try {
|
|
247
|
-
const refreshOptions = { ...options };
|
|
302
|
+
const refreshOptions = { ...options, timeout: CLI_OPERATION_TIMEOUT_MS };
|
|
248
303
|
refreshOptions.headers = { ...options.headers, Authorization: `Bearer ${tokens.refresh_token}` };
|
|
249
304
|
const refreshResponse = await httpRequest(refreshOptions, {
|
|
250
305
|
operation: 'refresh_operation_token',
|
|
@@ -325,7 +380,7 @@ async function cliOperations(req: any, skipResponseLog = false) {
|
|
|
325
380
|
name: 'payload',
|
|
326
381
|
filename: 'package.tar.gz',
|
|
327
382
|
contentType: 'application/gzip',
|
|
328
|
-
stream: packageStream,
|
|
383
|
+
stream: Readable.from(wrapPackagingStream(packageStream, req._projectPath)),
|
|
329
384
|
});
|
|
330
385
|
options.headers['Content-Type'] = multipart.contentType;
|
|
331
386
|
// Use chunked transfer-encoding: we don't know the total size up front because the
|
|
@@ -432,14 +487,23 @@ async function cliOperations(req: any, skipResponseLog = false) {
|
|
|
432
487
|
|
|
433
488
|
return responseData;
|
|
434
489
|
} catch (err) {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
490
|
+
let code, message, hostname;
|
|
491
|
+
try {
|
|
492
|
+
code = err?.code;
|
|
493
|
+
message = err?.message;
|
|
494
|
+
hostname = err?.hostname;
|
|
495
|
+
} catch {}
|
|
496
|
+
const isConnectionFailure = code === 'ENOENT' || code === 'ECONNREFUSED';
|
|
497
|
+
if (isConnectionFailure && !target) {
|
|
498
|
+
console.error(LOCAL_NOT_RUNNING_MESSAGE);
|
|
499
|
+
} else if (isConnectionFailure) {
|
|
500
|
+
console.error(`error: Failed to connect to Harper (${code}): ${message}`);
|
|
501
|
+
} else if (code === 'EACCES') {
|
|
502
|
+
console.error(`error: Permission denied accessing the domain socket: ${message}`);
|
|
503
|
+
} else if (code === 'ENOTFOUND') {
|
|
504
|
+
console.error(`error: Host not found: "${hostname}" ${message}`);
|
|
441
505
|
} else {
|
|
442
|
-
console.error(`error: ${
|
|
506
|
+
console.error(`error: ${message ?? err}`);
|
|
443
507
|
}
|
|
444
508
|
process.exit(1);
|
|
445
509
|
}
|
package/bin/run.ts
CHANGED
|
@@ -179,6 +179,16 @@ async function initialize(calledByInstall = false, calledByMain = false) {
|
|
|
179
179
|
process.exit(1);
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
// A built-in component only activates when its config key is present. Fresh installs get these
|
|
183
|
+
// keys from defaultConfig.yaml, but an in-place upgrade carries the old config forward without a
|
|
184
|
+
// key added in a newer release (harper-pro#585). Ensure them on every boot — idempotent and
|
|
185
|
+
// scoped to genuinely-new built-ins registered in this runtime — so the fix self-heals even when
|
|
186
|
+
// a version bump ships no upgrade directive or a prior backfill write failed.
|
|
187
|
+
const backfilledKeys = configUtils.ensureBuiltInComponentConfigKeys();
|
|
188
|
+
if (backfilledKeys.length > 0) {
|
|
189
|
+
hdbLogger.info(`Activated built-in component(s) absent from an upgraded config: ${backfilledKeys.join(', ')}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
182
192
|
checkJwtTokens();
|
|
183
193
|
|
|
184
194
|
await keys.reviewSelfSignedCert();
|
package/bin/status.ts
CHANGED
|
@@ -186,8 +186,10 @@ export function isSSHAuthFailure(stderr: string): boolean {
|
|
|
186
186
|
|
|
187
187
|
// Git-reference package identifier forms recognized below for the credentialed-clone path: the
|
|
188
188
|
// npm git-url spec forms this repo's own derivePackageIdentifier can produce for a git host
|
|
189
|
-
// credential
|
|
190
|
-
//
|
|
189
|
+
// credential, plus the raw forms a caller may pass directly (any identifier containing ':' is
|
|
190
|
+
// passed through as-is by derivePackageIdentifier). A form parseGitReference doesn't recognize but
|
|
191
|
+
// looksLikeGitReference does is treated as "recognized as git, but not safely handleable" (see
|
|
192
|
+
// extractApplication) rather than silently falling back to `npm pack --ignore-scripts`.
|
|
191
193
|
const GIT_URL_PREFIX = /^git\+(ssh|https?|file):\/\//i;
|
|
192
194
|
const GIT_PROTOCOL_PREFIX = /^git:\/\//i;
|
|
193
195
|
|
|
@@ -204,37 +206,89 @@ const HOSTED_GIT_HOSTS: Record<string, string> = {
|
|
|
204
206
|
};
|
|
205
207
|
const HOSTED_GIT_PREFIX = /^(github|gitlab|bitbucket|gist):(.+)$/i;
|
|
206
208
|
|
|
209
|
+
// A bare `https://`/`http://` URL to one of the hosts above is *also* a git-reference install, not a
|
|
210
|
+
// plain download: hosted-git-info (which npm's own git-arg resolution is built on) lists plain
|
|
211
|
+
// http/https in every host's `protocols` array, so `npm pack https://github.com/owner/repo` clones
|
|
212
|
+
// the repo exactly like the `git+https://` form does — it just doesn't carry the `git+` prefix that
|
|
213
|
+
// would otherwise mark it as one.
|
|
214
|
+
const BARE_GIT_HOST_URL_PREFIX = new RegExp(
|
|
215
|
+
`^https?://(${Object.values(HOSTED_GIT_HOSTS)
|
|
216
|
+
.map((host) => host.replace(/\./g, '\\.'))
|
|
217
|
+
.join('|')})/`,
|
|
218
|
+
'i'
|
|
219
|
+
);
|
|
220
|
+
|
|
207
221
|
interface GitReference {
|
|
208
222
|
cloneUrl: string;
|
|
209
223
|
committish?: string;
|
|
210
224
|
}
|
|
211
225
|
|
|
226
|
+
// True for a packageIdentifier that names a git source in some form parseGitReference recognizes OR
|
|
227
|
+
// doesn't (a malformed hosted shorthand, or a `#path:` committish naming npm's monorepo-subdirectory
|
|
228
|
+
// extension, which neither this nor the semver-committish resolution below implements). Lets
|
|
229
|
+
// extractApplication distinguish "not git at all" (safe to fall back to plain `npm pack
|
|
230
|
+
// --ignore-scripts`) from "git, but a form we can't safely reclone" (must fail loudly instead).
|
|
231
|
+
function looksLikeGitReference(packageIdentifier: string): boolean {
|
|
232
|
+
return (
|
|
233
|
+
GIT_URL_PREFIX.test(packageIdentifier) ||
|
|
234
|
+
GIT_PROTOCOL_PREFIX.test(packageIdentifier) ||
|
|
235
|
+
HOSTED_GIT_PREFIX.test(packageIdentifier) ||
|
|
236
|
+
BARE_GIT_HOST_URL_PREFIX.test(packageIdentifier)
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
212
240
|
/**
|
|
213
|
-
* Parses a `git+ssh://…`/`git+https://…`/`git+http://…`/`git+file://…`/`git://…`,
|
|
214
|
-
* shorthand (`github:owner/repo`,
|
|
215
|
-
* identifier into a plain
|
|
216
|
-
*
|
|
217
|
-
*
|
|
241
|
+
* Parses a `git+ssh://…`/`git+https://…`/`git+http://…`/`git+file://…`/`git://…`, a bare
|
|
242
|
+
* `https://`/`http://` URL to a known git host, or hosted-git shorthand (`github:owner/repo`,
|
|
243
|
+
* `gitlab:owner/repo`, `bitbucket:owner/repo`, `gist:[owner/]id`) package identifier into a plain
|
|
244
|
+
* clone URL and optional committish, without depending on npm's own git-spec parser
|
|
245
|
+
* (npm-package-arg/hosted-git-info aren't dependencies of this repo).
|
|
246
|
+
*
|
|
247
|
+
* Returns null for a `#path:` committish (npm's git-url monorepo-subdirectory extension, which this
|
|
248
|
+
* doesn't implement — a `#semver:` committish IS handled, downstream, by packGitReferenceWithoutScripts's
|
|
249
|
+
* resolveCommittish) or for a hosted shorthand that isn't a plain `owner/repo` (or `gist:[owner/]id`)
|
|
250
|
+
* — callers should treat that as "recognized as git, but not safely handleable" (see
|
|
251
|
+
* looksLikeGitReference) rather than silently falling back.
|
|
218
252
|
*/
|
|
219
253
|
export function parseGitReference(packageIdentifier: string): GitReference | null {
|
|
220
254
|
const hashIndex = packageIdentifier.indexOf('#');
|
|
221
255
|
const committish = hashIndex === -1 ? undefined : packageIdentifier.slice(hashIndex + 1);
|
|
256
|
+
if (committish?.startsWith('path:')) return null;
|
|
222
257
|
const spec = hashIndex === -1 ? packageIdentifier : packageIdentifier.slice(0, hashIndex);
|
|
223
258
|
if (GIT_URL_PREFIX.test(spec)) return { cloneUrl: spec.slice('git+'.length), committish };
|
|
224
259
|
if (GIT_PROTOCOL_PREFIX.test(spec)) return { cloneUrl: spec, committish };
|
|
260
|
+
// Both of these forms are ones npm resolves via hosted-git-info, which — unlike the explicit
|
|
261
|
+
// `git+`/`git:` URL forms above — URL-decodes the whole committish while parsing it (e.g. a
|
|
262
|
+
// branch name containing `/` arriving as `%2F`-escaped resolves correctly either way, but a
|
|
263
|
+
// committish using other reserved characters needs this to match a real ref name).
|
|
264
|
+
const decodedCommittish = committish === undefined ? undefined : decodeURIComponentOrRaw(committish);
|
|
265
|
+
if (BARE_GIT_HOST_URL_PREFIX.test(spec)) return { cloneUrl: spec, committish: decodedCommittish };
|
|
225
266
|
const hostedMatch = HOSTED_GIT_PREFIX.exec(spec);
|
|
226
267
|
if (hostedMatch) {
|
|
227
268
|
const [, prefix, path] = hostedMatch;
|
|
228
269
|
const host = HOSTED_GIT_HOSTS[prefix.toLowerCase()];
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
270
|
+
if (prefix.toLowerCase() === 'gist') {
|
|
271
|
+
// A gist clone URL is keyed by id alone; an optional `owner/` in the shorthand (npm
|
|
272
|
+
// accepts `gist:[owner/]id`) has no place in the URL and is dropped.
|
|
273
|
+
const id = path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path;
|
|
274
|
+
if (!/^[^/#:]+$/.test(id)) return null;
|
|
275
|
+
return { cloneUrl: `https://${host}/${id}.git`, committish: decodedCommittish };
|
|
276
|
+
}
|
|
277
|
+
if (!/^[^/#:]+\/[^/#:]+$/.test(path)) return null;
|
|
278
|
+
return { cloneUrl: `https://${host}/${path}.git`, committish: decodedCommittish };
|
|
234
279
|
}
|
|
235
280
|
return null;
|
|
236
281
|
}
|
|
237
282
|
|
|
283
|
+
/** decodeURIComponent, falling back to the raw input on a malformed escape rather than throwing. */
|
|
284
|
+
function decodeURIComponentOrRaw(value: string): string {
|
|
285
|
+
try {
|
|
286
|
+
return decodeURIComponent(value);
|
|
287
|
+
} catch {
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
238
292
|
const NEUTRALIZED_LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepack', 'prepare'];
|
|
239
293
|
|
|
240
294
|
// npm's hosted-git-info convention (documented in #1799's own worked example, `github:my-org/my-app#semver:v1.2.3`)
|
|
@@ -266,13 +320,8 @@ async function resolveCommittish(application: Application, committish: string, c
|
|
|
266
320
|
if (!SEMVER_COMMITTISH_PREFIX.test(committish)) return committish;
|
|
267
321
|
// npm's own package-arg parser URL-decodes the value after `semver:` (a range containing `^`/`~`
|
|
268
322
|
// can arrive percent-encoded, e.g. `#semver:%5E1.0.0`); do the same rather than evaluating it raw.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
try {
|
|
272
|
-
range = decodeURIComponent(rawRange);
|
|
273
|
-
} catch {
|
|
274
|
-
range = rawRange;
|
|
275
|
-
}
|
|
323
|
+
// (A harmless no-op if parseGitReference already decoded the whole committish upstream.)
|
|
324
|
+
const range = decodeURIComponentOrRaw(committish.slice('semver:'.length));
|
|
276
325
|
|
|
277
326
|
// `git tag --list` rather than `for-each-ref --format=...`: nonInteractiveSpawn runs through a
|
|
278
327
|
// shell, and a `%(...)` format string is unsafe to pass through one.
|
|
@@ -371,33 +420,55 @@ async function packGitReferenceWithoutScripts(
|
|
|
371
420
|
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
372
421
|
}
|
|
373
422
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
423
|
+
return await runNpmPack(application, ['pack', '--json', '--ignore-scripts', cloneDir], parentDirPath);
|
|
424
|
+
} finally {
|
|
425
|
+
await rm(cloneDir, { recursive: true, force: true });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Runs `npm pack` with the given args and returns the resulting tarball's path under `cwd`. Shared
|
|
431
|
+
* between the git-reference reclone path above and the plain identifier path in extractApplication.
|
|
432
|
+
*/
|
|
433
|
+
async function runNpmPack(
|
|
434
|
+
application: Application,
|
|
435
|
+
packArgs: string[],
|
|
436
|
+
cwd: string,
|
|
437
|
+
gitCredentialEnv?: Record<string, string>
|
|
438
|
+
): Promise<string> {
|
|
439
|
+
const { stdout, code, stderr } = await nonInteractiveSpawn(
|
|
440
|
+
application.name,
|
|
441
|
+
'npm',
|
|
442
|
+
packArgs,
|
|
443
|
+
cwd,
|
|
444
|
+
undefined,
|
|
445
|
+
undefined,
|
|
446
|
+
application.npmUserconfigPath,
|
|
447
|
+
gitCredentialEnv
|
|
448
|
+
);
|
|
449
|
+
if (code !== 0) {
|
|
450
|
+
if (isSSHAuthFailure(stderr)) {
|
|
390
451
|
throw new Error(
|
|
391
|
-
`Failed to
|
|
452
|
+
`Failed to deploy private repository ${application.packageIdentifier}: SSH access failed. Verify the repository URL, configure an SSH key on this Harper instance, ensure the key has access to the target repository, and confirm the host is present in the ssh/known_hosts file.`,
|
|
453
|
+
{ cause: new Error(stderr) }
|
|
392
454
|
);
|
|
393
455
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
456
|
+
throw new Error(`Failed to download package ${application.packageIdentifier}: ${stderr}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let packResult: Array<{ filename: string }>;
|
|
460
|
+
try {
|
|
461
|
+
packResult = JSON.parse(stdout.slice(stdout.indexOf('[')));
|
|
462
|
+
} catch (err) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`Failed to parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') {
|
|
468
|
+
throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
|
|
400
469
|
}
|
|
470
|
+
|
|
471
|
+
return join(cwd, packResult[0].filename);
|
|
401
472
|
}
|
|
402
473
|
|
|
403
474
|
// Hidden directory under the components root holding component versions renamed aside
|
|
@@ -488,24 +559,37 @@ export async function extractApplication(application: Application) {
|
|
|
488
559
|
//
|
|
489
560
|
// Packing a git reference is not just a download: npm clones the repo and, if its manifest
|
|
490
561
|
// has a prepare/build/install script, runs `npm install` inside the clone and then that
|
|
491
|
-
// script — so the
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
562
|
+
// script — so the repository's (and its dependencies') install scripts can execute on this
|
|
563
|
+
// node during the pack step alone, independent of the later `npm install`.
|
|
564
|
+
// `install_allow_scripts` is the operator-facing switch for whether component code is
|
|
565
|
+
// allowed to run scripts on this node at all — with a credential session live, an
|
|
566
|
+
// unreviewed script also reaching the socket (a transitive dependency's postinstall asking
|
|
567
|
+
// for a token granted to the top-level repository) is exactly the reach the credential must
|
|
568
|
+
// not have, so this gates the pack step regardless of whether a credential happens to be in
|
|
569
|
+
// play.
|
|
570
|
+
const allowScripts = !!application.install?.allowInstallScripts;
|
|
497
571
|
// `--ignore-scripts` alone isn't a reliable way to enforce that: pacote's DirFetcher runs a
|
|
498
572
|
// git source's `prepare` unconditionally on npm versions before 11.0.0 (see
|
|
499
573
|
// packGitReferenceWithoutScripts), which is exactly what Node 22's bundled npm ships. For a
|
|
500
574
|
// recognized git-reference identifier, clone and pack it ourselves with scripts stripped
|
|
501
575
|
// instead, sidestepping that npm code path entirely.
|
|
502
|
-
const gitRef =
|
|
576
|
+
const gitRef = allowScripts ? null : parseGitReference(application.packageIdentifier);
|
|
577
|
+
|
|
578
|
+
if (!allowScripts && !gitRef && looksLikeGitReference(application.packageIdentifier)) {
|
|
579
|
+
// Recognized as git, but a form the reclone-and-strip-scripts path above can't safely
|
|
580
|
+
// handle (a `#path:` committish, or a hosted shorthand other than a plain `owner/repo`) —
|
|
581
|
+
// fail loudly rather than silently falling through to the unreliable `npm pack
|
|
582
|
+
// --ignore-scripts` below.
|
|
583
|
+
throw new Error(
|
|
584
|
+
`Cannot deploy git-reference package '${application.packageIdentifier}' with install scripts disallowed: this identifier's form (e.g. a '#path:' committish, or a hosted shorthand other than a plain 'owner/repo') isn't one this repo's script-suppression handling supports. Set install.allowInstallScripts to true, or use a plain git URL with a branch/tag/commit committish instead.`
|
|
585
|
+
);
|
|
586
|
+
}
|
|
503
587
|
|
|
504
588
|
if (gitRef) {
|
|
505
589
|
tarballPath = await packGitReferenceWithoutScripts(application, gitRef, parentDirPath);
|
|
506
590
|
} else {
|
|
507
591
|
const packArgs = ['pack', '--json', application.packageIdentifier];
|
|
508
|
-
if (
|
|
592
|
+
if (!allowScripts) {
|
|
509
593
|
packArgs.push('--ignore-scripts');
|
|
510
594
|
} else if (application.gitCredentialEnv) {
|
|
511
595
|
application.logger.warn(
|
|
@@ -514,39 +598,7 @@ export async function extractApplication(application: Application) {
|
|
|
514
598
|
`can read the git credential. Unset install_allow_scripts to keep the credential out of their reach.`
|
|
515
599
|
);
|
|
516
600
|
}
|
|
517
|
-
|
|
518
|
-
application.name,
|
|
519
|
-
'npm',
|
|
520
|
-
packArgs,
|
|
521
|
-
parentDirPath,
|
|
522
|
-
undefined,
|
|
523
|
-
undefined,
|
|
524
|
-
application.npmUserconfigPath,
|
|
525
|
-
application.gitCredentialEnv
|
|
526
|
-
);
|
|
527
|
-
if (code !== 0) {
|
|
528
|
-
if (isSSHAuthFailure(stderr)) {
|
|
529
|
-
throw new Error(
|
|
530
|
-
`Failed to deploy private repository ${application.packageIdentifier}: SSH access failed. Verify the repository URL, configure an SSH key on this Harper instance, ensure the key has access to the target repository, and confirm the host is present in the ssh/known_hosts file.`,
|
|
531
|
-
{ cause: new Error(stderr) }
|
|
532
|
-
);
|
|
533
|
-
}
|
|
534
|
-
throw new Error(`Failed to download package ${application.packageIdentifier}: ${stderr}`);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
let packResult: Array<{ filename: string }>;
|
|
538
|
-
try {
|
|
539
|
-
packResult = JSON.parse(stdout.slice(stdout.indexOf('[')));
|
|
540
|
-
} catch (err) {
|
|
541
|
-
throw new Error(
|
|
542
|
-
`Failed to parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`
|
|
543
|
-
);
|
|
544
|
-
}
|
|
545
|
-
if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') {
|
|
546
|
-
throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
tarballPath = join(parentDirPath, packResult[0].filename);
|
|
601
|
+
tarballPath = await runNpmPack(application, packArgs, parentDirPath, application.gitCredentialEnv);
|
|
550
602
|
}
|
|
551
603
|
shouldDeleteTarball = true;
|
|
552
604
|
tarball = createReadStream(tarballPath);
|
|
@@ -582,6 +634,9 @@ export async function extractApplication(application: Application) {
|
|
|
582
634
|
throw err;
|
|
583
635
|
}
|
|
584
636
|
}
|
|
637
|
+
// A directory existed for this component name prior to this deploy, so this is a redeploy of
|
|
638
|
+
// an already-active component rather than a first-time deploy. See `isNewComponent` above.
|
|
639
|
+
if (didRenameAside) application.isNewComponent = false;
|
|
585
640
|
// Finally, create the application directory fresh
|
|
586
641
|
await mkdir(application.dirPath, { recursive: true });
|
|
587
642
|
|
|
@@ -848,6 +903,14 @@ export class Application {
|
|
|
848
903
|
npmUserconfigPath?: string;
|
|
849
904
|
#npmrcTempDir?: string;
|
|
850
905
|
#gitCredentialSession?: GitCredentialSession;
|
|
906
|
+
// Whether this component's directory did not already exist when extractApplication ran —
|
|
907
|
+
// i.e. this deploy is the component's first, as opposed to a redeploy of something already
|
|
908
|
+
// active. Defaults true and is flipped to false by extractApplication when it finds (and
|
|
909
|
+
// renames aside) a pre-existing directory for this component name. Used by deployComponent to
|
|
910
|
+
// scope its unconditional requestRestart() call to genuinely new components (harper#1806):
|
|
911
|
+
// an existing, already-loaded component already has a live file watcher (Scope/EntryHandler)
|
|
912
|
+
// that independently requests a restart if the redeploy actually needs one.
|
|
913
|
+
isNewComponent: boolean = true;
|
|
851
914
|
|
|
852
915
|
constructor({ name, payload, packageIdentifier, install, onInstallLine, credentials }: ApplicationOptions) {
|
|
853
916
|
this.name = name;
|
package/components/Scope.ts
CHANGED
|
@@ -76,6 +76,10 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
|
|
|
76
76
|
ready: Promise<any[]>;
|
|
77
77
|
databaseEvents: typeof databaseEventsEmitter;
|
|
78
78
|
models: Models;
|
|
79
|
+
// Set by the loader on deploy pre-flight validation loads (collectScopes):
|
|
80
|
+
// the scope exists to validate a component, not to run it. Plugins with
|
|
81
|
+
// process-global side effects should validate fully but skip activation.
|
|
82
|
+
isTransientValidation?: boolean;
|
|
79
83
|
|
|
80
84
|
constructor(
|
|
81
85
|
appName: string,
|
|
@@ -25,6 +25,7 @@ import * as staticFiles from '../server/static.ts';
|
|
|
25
25
|
import * as loadEnv from '../resources/loadEnv.ts';
|
|
26
26
|
import harperLogger, { errorForLog } from '../utility/logging/harper_logger.ts';
|
|
27
27
|
import * as dataLoader from '../resources/dataLoader.ts';
|
|
28
|
+
import * as scheduler from '../resources/scheduler/scheduler.ts';
|
|
28
29
|
import { restartWorkers, getWorkerIndex } from '../server/threads/manageThreads.js';
|
|
29
30
|
import { resetRestartNeeded, subscribeToRestartRequests } from './requestRestart.ts';
|
|
30
31
|
import { trackScopeClose } from './scopeShutdown.ts';
|
|
@@ -121,6 +122,7 @@ export const TRUSTED_RESOURCE_PLUGINS: any = {
|
|
|
121
122
|
logging: harperLogger,
|
|
122
123
|
dataLoader,
|
|
123
124
|
mcp: mcpComponent,
|
|
125
|
+
scheduler,
|
|
124
126
|
/*
|
|
125
127
|
static: ...
|
|
126
128
|
login: ...
|
|
@@ -560,6 +562,11 @@ export async function loadComponent(
|
|
|
560
562
|
// load is validated (see operations.js deploy pre-flight). Skip the worker-shutdown
|
|
561
563
|
// auto-close so their deploy-lifecycle listeners — and this SHUTDOWN handler — don't
|
|
562
564
|
// accumulate across deploys (#1462).
|
|
565
|
+
// Mark it so plugins with process-global side effects (e.g. the scheduler
|
|
566
|
+
// registering jobs into its engine) can validate without activating —
|
|
567
|
+
// validation scopes may reuse a live component's identity, so activating
|
|
568
|
+
// from one can displace the real component's registrations.
|
|
569
|
+
scope.isTransientValidation = true;
|
|
563
570
|
options.collectScopes.add(scope);
|
|
564
571
|
} else {
|
|
565
572
|
// Track the close so the worker's shutdown path waits for it (and thus for any async
|
package/components/operations.js
CHANGED
|
@@ -611,7 +611,27 @@ async function deployComponent(req) {
|
|
|
611
611
|
|
|
612
612
|
response.restartJobId = jobResponse.job_id;
|
|
613
613
|
response.message = `Successfully deployed: ${application.name}, restarting Harper`;
|
|
614
|
-
} else
|
|
614
|
+
} else {
|
|
615
|
+
// Deployed without restarting: for a component that had no directory before this
|
|
616
|
+
// deploy — genuinely new, never loaded — its routes cannot be live until Harper
|
|
617
|
+
// restarts, so mark a restart as needed. This is the setter only; it does not itself
|
|
618
|
+
// restart. It makes get_status report restartRequired:true and lets the REST
|
|
619
|
+
// route-miss path surface the actionable "needs a restart" 404 for a freshly deployed,
|
|
620
|
+
// never-loaded component (harper#674). Runs per-node: a peer applying the replicated
|
|
621
|
+
// deploy checks its own local isNewComponent, since directory state (and therefore
|
|
622
|
+
// whether the component was already active) can differ per node.
|
|
623
|
+
//
|
|
624
|
+
// An existing, already-active component being redeployed does NOT force a restart
|
|
625
|
+
// here: some updates (e.g. static files only) may not need one at all, and when one
|
|
626
|
+
// genuinely is needed, that component's already-running file watcher (Scope/
|
|
627
|
+
// EntryHandler, see deployLifecycle.ts) independently detects the post-deploy file
|
|
628
|
+
// changes and requests the restart itself.
|
|
629
|
+
if (application.isNewComponent) {
|
|
630
|
+
const { requestRestart } = require('./requestRestart.ts');
|
|
631
|
+
requestRestart();
|
|
632
|
+
}
|
|
633
|
+
response.message = `Successfully deployed: ${application.name}`;
|
|
634
|
+
}
|
|
615
635
|
|
|
616
636
|
// Replication failures don't reject replicateOperation — they surface as 'failed'
|
|
617
637
|
// entries in peer_results. By default, treat any failed peer as an overall deploy
|