@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.
Files changed (137) hide show
  1. package/bin/cliOperations.ts +76 -12
  2. package/bin/run.ts +10 -0
  3. package/bin/status.ts +1 -1
  4. package/components/Application.ts +146 -83
  5. package/components/Scope.ts +4 -0
  6. package/components/componentLoader.ts +7 -0
  7. package/components/operations.js +21 -1
  8. package/config/configUtils.ts +139 -5
  9. package/config-app.schema.json +70 -0
  10. package/dist/bin/cliOperations.js +76 -12
  11. package/dist/bin/cliOperations.js.map +1 -1
  12. package/dist/bin/run.js +9 -0
  13. package/dist/bin/run.js.map +1 -1
  14. package/dist/bin/status.js +1 -1
  15. package/dist/bin/status.js.map +1 -1
  16. package/dist/components/Application.d.ts +12 -5
  17. package/dist/components/Application.js +121 -60
  18. package/dist/components/Application.js.map +1 -1
  19. package/dist/components/Scope.d.ts +1 -0
  20. package/dist/components/Scope.js +4 -0
  21. package/dist/components/Scope.js.map +1 -1
  22. package/dist/components/componentLoader.js +7 -0
  23. package/dist/components/componentLoader.js.map +1 -1
  24. package/dist/components/operations.js +20 -1
  25. package/dist/components/operations.js.map +1 -1
  26. package/dist/config/configUtils.d.ts +31 -0
  27. package/dist/config/configUtils.js +127 -5
  28. package/dist/config/configUtils.js.map +1 -1
  29. package/dist/resources/DatabaseTransaction.d.ts +12 -0
  30. package/dist/resources/DatabaseTransaction.js +97 -0
  31. package/dist/resources/DatabaseTransaction.js.map +1 -1
  32. package/dist/resources/RequestTarget.js +13 -3
  33. package/dist/resources/RequestTarget.js.map +1 -1
  34. package/dist/resources/Resource.js +16 -0
  35. package/dist/resources/Resource.js.map +1 -1
  36. package/dist/resources/Table.js +35 -2
  37. package/dist/resources/Table.js.map +1 -1
  38. package/dist/resources/analytics/metadata.d.ts +3 -0
  39. package/dist/resources/analytics/metadata.js +3 -0
  40. package/dist/resources/analytics/metadata.js.map +1 -1
  41. package/dist/resources/analytics/write.js +22 -0
  42. package/dist/resources/analytics/write.js.map +1 -1
  43. package/dist/resources/defineResource.js +20 -7
  44. package/dist/resources/defineResource.js.map +1 -1
  45. package/dist/resources/jsResource.d.ts +24 -0
  46. package/dist/resources/jsResource.js +58 -2
  47. package/dist/resources/jsResource.js.map +1 -1
  48. package/dist/resources/openApi.js +45 -20
  49. package/dist/resources/openApi.js.map +1 -1
  50. package/dist/resources/scheduler/CronExpression.d.ts +71 -0
  51. package/dist/resources/scheduler/CronExpression.js +367 -0
  52. package/dist/resources/scheduler/CronExpression.js.map +1 -0
  53. package/dist/resources/scheduler/engine.d.ts +91 -0
  54. package/dist/resources/scheduler/engine.js +767 -0
  55. package/dist/resources/scheduler/engine.js.map +1 -0
  56. package/dist/resources/scheduler/scheduler.d.ts +33 -0
  57. package/dist/resources/scheduler/scheduler.js +200 -0
  58. package/dist/resources/scheduler/scheduler.js.map +1 -0
  59. package/dist/security/auth.js +1 -0
  60. package/dist/security/auth.js.map +1 -1
  61. package/dist/security/jsLoader.js +8 -0
  62. package/dist/security/jsLoader.js.map +1 -1
  63. package/dist/security/keys.d.ts +32 -0
  64. package/dist/security/keys.js +147 -0
  65. package/dist/security/keys.js.map +1 -1
  66. package/dist/server/REST.js +67 -1
  67. package/dist/server/REST.js.map +1 -1
  68. package/dist/server/Server.d.ts +6 -0
  69. package/dist/server/Server.js.map +1 -1
  70. package/dist/server/http.d.ts +2 -0
  71. package/dist/server/http.js +139 -14
  72. package/dist/server/http.js.map +1 -1
  73. package/dist/server/operationsServer.js +3 -3
  74. package/dist/server/operationsServer.js.map +1 -1
  75. package/dist/server/serverHelpers/progressEmitter.js +5 -1
  76. package/dist/server/serverHelpers/progressEmitter.js.map +1 -1
  77. package/dist/server/threads/threadServer.js +9 -5
  78. package/dist/server/threads/threadServer.js.map +1 -1
  79. package/dist/utility/common_utils.js +25 -0
  80. package/dist/utility/common_utils.js.map +1 -1
  81. package/dist/utility/install/installer.d.ts +9 -1
  82. package/dist/utility/install/installer.js +21 -0
  83. package/dist/utility/install/installer.js.map +1 -1
  84. package/dist/validation/configValidator.js +3 -0
  85. package/dist/validation/configValidator.js.map +1 -1
  86. package/npm-shrinkwrap.json +272 -230
  87. package/package.json +3 -3
  88. package/resources/DESIGN.md +1 -1
  89. package/resources/DatabaseTransaction.ts +95 -0
  90. package/resources/RequestTarget.ts +12 -3
  91. package/resources/Resource.ts +18 -0
  92. package/resources/Table.ts +34 -3
  93. package/resources/analytics/metadata.ts +3 -0
  94. package/resources/analytics/write.ts +23 -0
  95. package/resources/defineResource.ts +17 -4
  96. package/resources/jsResource.ts +61 -2
  97. package/resources/openApi.ts +44 -19
  98. package/resources/scheduler/CronExpression.ts +394 -0
  99. package/resources/scheduler/engine.ts +812 -0
  100. package/resources/scheduler/scheduler.ts +236 -0
  101. package/security/auth.ts +1 -0
  102. package/security/jsLoader.ts +8 -0
  103. package/security/keys.ts +152 -0
  104. package/server/REST.ts +70 -1
  105. package/server/Server.ts +6 -0
  106. package/server/http.ts +122 -15
  107. package/server/operationsServer.ts +5 -3
  108. package/server/serverHelpers/progressEmitter.ts +5 -1
  109. package/server/threads/threadServer.js +9 -5
  110. package/studio/web/assets/{Chat-BZks8dVF.js → Chat-DHP4XpID.js} +2 -2
  111. package/studio/web/assets/{Chat-BZks8dVF.js.map → Chat-DHP4XpID.js.map} +1 -1
  112. package/studio/web/assets/{FloatingChat-Dic8paVO.js → FloatingChat-CJ7PssCv.js} +4 -4
  113. package/studio/web/assets/{FloatingChat-Dic8paVO.js.map → FloatingChat-CJ7PssCv.js.map} +1 -1
  114. package/studio/web/assets/{applications-uOXkeUIN.js → applications-DxXiGpsR.js} +2 -2
  115. package/studio/web/assets/{applications-uOXkeUIN.js.map → applications-DxXiGpsR.js.map} +1 -1
  116. package/studio/web/assets/{index-i-2wrKhv.js → index-BdbBanDP.js} +6 -6
  117. package/studio/web/assets/{index-i-2wrKhv.js.map → index-BdbBanDP.js.map} +1 -1
  118. package/studio/web/assets/{index.lazy-Csk8eCoB.js → index.lazy-B2eH28zD.js} +4 -4
  119. package/studio/web/assets/{index.lazy-Csk8eCoB.js.map → index.lazy-B2eH28zD.js.map} +1 -1
  120. package/studio/web/assets/{profile-Sb3mGDl6.js → profile-DK5hgucv.js} +2 -2
  121. package/studio/web/assets/{profile-Sb3mGDl6.js.map → profile-DK5hgucv.js.map} +1 -1
  122. package/studio/web/assets/{setComponentFile-BgZcaPJ2.js → setComponentFile-BVDWRYxx.js} +2 -2
  123. package/studio/web/assets/{setComponentFile-BgZcaPJ2.js.map → setComponentFile-BVDWRYxx.js.map} +1 -1
  124. package/studio/web/assets/{setup-DKtlLgmT.js → setup-DJ9BInoK.js} +2 -2
  125. package/studio/web/assets/{setup-DKtlLgmT.js.map → setup-DJ9BInoK.js.map} +1 -1
  126. package/studio/web/assets/{status-B45iLeug.js → status-B_qzmgfD.js} +2 -2
  127. package/studio/web/assets/{status-B45iLeug.js.map → status-B_qzmgfD.js.map} +1 -1
  128. package/studio/web/assets/{swagger-ui-react-Csu4026e.js → swagger-ui-react-DOL5jCqg.js} +2 -2
  129. package/studio/web/assets/{swagger-ui-react-Csu4026e.js.map → swagger-ui-react-DOL5jCqg.js.map} +1 -1
  130. package/studio/web/assets/{tsMode-DVgxUr_l.js → tsMode-DpxUxfTW.js} +2 -2
  131. package/studio/web/assets/{tsMode-DVgxUr_l.js.map → tsMode-DpxUxfTW.js.map} +1 -1
  132. package/studio/web/assets/{useEntityRestURL-yfDQMV1f.js → useEntityRestURL-CU_lY6XW.js} +2 -2
  133. package/studio/web/assets/{useEntityRestURL-yfDQMV1f.js.map → useEntityRestURL-CU_lY6XW.js.map} +1 -1
  134. package/studio/web/index.html +1 -1
  135. package/utility/common_utils.ts +26 -0
  136. package/utility/install/installer.ts +26 -1
  137. package/validation/configValidator.ts +3 -0
@@ -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 = { ...options, headers: { ...options.headers, Accept: 'application/json' } };
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('Harper must be running to perform this operation');
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('No domain socket found, unable to perform this operation');
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
- if (err.code === 'ENOENT' || err.code === 'ECONNREFUSED') {
436
- console.error(`error: Failed to connect to Harper (${err.code}): ${err.message}`);
437
- } else if (err.code === 'EACCES') {
438
- console.error(`error: Permission denied accessing the domain socket: ${err.message}`);
439
- } else if (err.code === 'ENOTFOUND') {
440
- console.error(`error: Host not found: "${err.hostname}" ${err.message}`);
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: ${err.message ?? err}`);
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
@@ -61,5 +61,5 @@ async function status() {
61
61
  }
62
62
 
63
63
  console.log(YAML.stringify(status));
64
- process.exit();
64
+ process.exit(0);
65
65
  }
@@ -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. An identifier that doesn't match falls back to `npm pack --ignore-scripts` (best
190
- // effort, same as before this fix — not a regression for a form this can't safely reclone).
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://…`, or hosted-git
214
- * shorthand (`github:owner/repo`, `gitlab:owner/repo`, `bitbucket:owner/repo`, `gist:id`) package
215
- * identifier into a plain clone URL and optional committish, without depending on npm's own git-spec
216
- * parser (npm-package-arg/hosted-git-info aren't dependencies of this repo). Returns null for any
217
- * other form.
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
- // A gist clone URL is keyed by id alone; an optional `owner/` in the shorthand (npm accepts
230
- // `gist:[owner/]id`) has no place in the URL and is dropped.
231
- const urlPath =
232
- prefix.toLowerCase() === 'gist' && path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path;
233
- return { cloneUrl: `https://${host}/${urlPath}.git`, committish };
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
- const rawRange = committish.slice('semver:'.length);
270
- let range: string;
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
- const { stdout, code, stderr } = await nonInteractiveSpawn(
375
- application.name,
376
- 'npm',
377
- ['pack', '--json', '--ignore-scripts', cloneDir],
378
- parentDirPath,
379
- undefined,
380
- undefined,
381
- application.npmUserconfigPath
382
- );
383
- if (code !== 0) {
384
- throw new Error(`Failed to pack package ${application.packageIdentifier}: ${stderr}`);
385
- }
386
- let packResult: Array<{ filename: string }>;
387
- try {
388
- packResult = JSON.parse(stdout.slice(stdout.indexOf('[')));
389
- } catch (err) {
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 parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`
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
- if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') {
395
- throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
396
- }
397
- return join(parentDirPath, packResult[0].filename);
398
- } finally {
399
- await rm(cloneDir, { recursive: true, force: true });
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 repo's own code AND its dependencies' install scripts execute on this node,
492
- // inheriting this spawn's environment. With a credential session live, that is exactly the
493
- // reach the credential must not have (a transitive dependency's postinstall could ask the
494
- // socket for a token granted for the top-level repository), so scripts are off for a
495
- // credentialed clone unless the deploy explicitly opted into them.
496
- const scriptsDisallowed = application.gitCredentialEnv && !application.install?.allowInstallScripts;
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 = scriptsDisallowed ? parseGitReference(application.packageIdentifier) : null;
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 (scriptsDisallowed) {
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
- const { stdout, code, stderr } = await nonInteractiveSpawn(
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;
@@ -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
@@ -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 response.message = `Successfully deployed: ${application.name}`;
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