@harperfast/harper 5.2.0-alpha.4 → 5.2.0-alpha.6
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/agent/agent.ts +152 -10
- package/agent/bestPractices.ts +58 -0
- package/agent/mcpTools.ts +122 -0
- package/agent/operations.ts +10 -1
- package/agent/registryTools.ts +117 -0
- package/agent/session.ts +16 -7
- package/agent/tools/fsTools.ts +74 -27
- package/agent/tools/inspectorTool.ts +459 -0
- package/agent/toolset.ts +27 -7
- package/agent/types.ts +2 -0
- package/components/Application.ts +566 -85
- package/components/OptionsWatcher.ts +62 -2
- package/components/Scope.ts +31 -8
- package/components/componentLoader.ts +5 -1
- package/components/componentSecrets.ts +514 -55
- package/components/deploymentRecorder.ts +7 -6
- package/components/gitCredentialHelper.js +115 -0
- package/components/gitCredentialServer.ts +251 -0
- package/components/mcp/tools/application.ts +197 -23
- package/components/mcp/tools/operations.ts +1 -1
- package/components/mcp/tools/schemas/derive.ts +6 -2
- package/components/operations.js +21 -19
- package/components/operationsValidation.js +86 -21
- package/components/secretOperations.ts +110 -30
- package/config/harperConfigEnvVars.ts +90 -5
- package/config-root.schema.json +4 -0
- package/dist/agent/agent.d.ts +24 -3
- package/dist/agent/agent.js +172 -10
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/bestPractices.d.ts +24 -0
- package/dist/agent/bestPractices.js +60 -0
- package/dist/agent/bestPractices.js.map +1 -0
- package/dist/agent/mcpTools.d.ts +24 -0
- package/dist/agent/mcpTools.js +115 -0
- package/dist/agent/mcpTools.js.map +1 -0
- package/dist/agent/operations.js +10 -1
- package/dist/agent/operations.js.map +1 -1
- package/dist/agent/registryTools.d.ts +45 -0
- package/dist/agent/registryTools.js +113 -0
- package/dist/agent/registryTools.js.map +1 -0
- package/dist/agent/session.js +16 -7
- package/dist/agent/session.js.map +1 -1
- package/dist/agent/tools/fsTools.js +70 -28
- package/dist/agent/tools/fsTools.js.map +1 -1
- package/dist/agent/tools/inspectorTool.d.ts +40 -0
- package/dist/agent/tools/inspectorTool.js +428 -0
- package/dist/agent/tools/inspectorTool.js.map +1 -0
- package/dist/agent/toolset.d.ts +16 -6
- package/dist/agent/toolset.js +17 -7
- package/dist/agent/toolset.js.map +1 -1
- package/dist/agent/types.d.ts +2 -0
- package/dist/components/Application.d.ts +73 -18
- package/dist/components/Application.js +475 -64
- package/dist/components/Application.js.map +1 -1
- package/dist/components/OptionsWatcher.d.ts +1 -1
- package/dist/components/OptionsWatcher.js +65 -2
- package/dist/components/OptionsWatcher.js.map +1 -1
- package/dist/components/Scope.d.ts +8 -6
- package/dist/components/Scope.js +22 -6
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +5 -1
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/componentSecrets.d.ts +33 -7
- package/dist/components/componentSecrets.js +473 -59
- package/dist/components/componentSecrets.js.map +1 -1
- package/dist/components/deploymentRecorder.d.ts +2 -2
- package/dist/components/deploymentRecorder.js +1 -1
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/gitCredentialHelper.d.ts +1 -0
- package/dist/components/gitCredentialHelper.js +113 -0
- package/dist/components/gitCredentialHelper.js.map +1 -0
- package/dist/components/gitCredentialServer.d.ts +33 -0
- package/dist/components/gitCredentialServer.js +236 -0
- package/dist/components/gitCredentialServer.js.map +1 -0
- package/dist/components/mcp/tools/application.d.ts +19 -0
- package/dist/components/mcp/tools/application.js +180 -22
- package/dist/components/mcp/tools/application.js.map +1 -1
- package/dist/components/mcp/tools/operations.d.ts +13 -0
- package/dist/components/mcp/tools/operations.js +1 -0
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/mcp/tools/schemas/derive.js +6 -2
- package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
- package/dist/components/operations.js +23 -21
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +84 -21
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/secretOperations.d.ts +33 -11
- package/dist/components/secretOperations.js +90 -26
- package/dist/components/secretOperations.js.map +1 -1
- package/dist/config/harperConfigEnvVars.d.ts +21 -0
- package/dist/config/harperConfigEnvVars.js +95 -5
- package/dist/config/harperConfigEnvVars.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/resources/Resource.d.ts +19 -0
- package/dist/resources/Resource.js +93 -2
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/Table.d.ts +9 -0
- package/dist/resources/Table.js +256 -50
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/read.js +28 -25
- package/dist/resources/analytics/read.js.map +1 -1
- package/dist/resources/defineResource.d.ts +180 -0
- package/dist/resources/defineResource.js +505 -0
- package/dist/resources/defineResource.js.map +1 -0
- package/dist/resources/defineTable.d.ts +221 -0
- package/dist/resources/defineTable.js +227 -0
- package/dist/resources/defineTable.js.map +1 -0
- package/dist/resources/jsonSchemaTypes.d.ts +2 -0
- package/dist/resources/jsonSchemaTypes.js +12 -4
- package/dist/resources/jsonSchemaTypes.js.map +1 -1
- package/dist/resources/openApi.js +69 -13
- package/dist/resources/openApi.js.map +1 -1
- package/dist/resources/search.js +6 -8
- package/dist/resources/search.js.map +1 -1
- package/dist/server/graphqlQuerying.js +4 -2
- package/dist/server/graphqlQuerying.js.map +1 -1
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +35 -15
- package/dist/server/http.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +8 -5
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +23 -0
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/utility/errors/hdbError.d.ts +21 -0
- package/dist/utility/errors/hdbError.js +23 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/validation/configValidator.js +22 -6
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +33 -0
- package/npm-shrinkwrap.json +9197 -15236
- package/package.json +5 -3
- package/resources/DESIGN.md +42 -15
- package/resources/Resource.ts +99 -2
- package/resources/Table.ts +275 -71
- package/resources/analytics/read.ts +30 -25
- package/resources/defineResource.ts +651 -0
- package/resources/defineTable.ts +407 -0
- package/resources/jsonSchemaTypes.ts +12 -4
- package/resources/openApi.ts +68 -16
- package/resources/search.ts +5 -8
- package/server/graphqlQuerying.ts +4 -2
- package/server/http.ts +40 -16
- package/server/serverHelpers/serverUtilities.ts +19 -6
- package/server/serverHelpers/uwsServer.ts +24 -0
- package/studio/web/assets/{Chat-D4FIaBu7.js → Chat-CTjtL8Z4.js} +2 -2
- package/studio/web/assets/{Chat-D4FIaBu7.js.map → Chat-CTjtL8Z4.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-DGUC3L4r.js → FloatingChat-CafHR4Ur.js} +4 -4
- package/studio/web/assets/{FloatingChat-DGUC3L4r.js.map → FloatingChat-CafHR4Ur.js.map} +1 -1
- package/studio/web/assets/{applications-D8Am9ikM.js → applications-Buh_q0Vj.js} +2 -2
- package/studio/web/assets/{applications-D8Am9ikM.js.map → applications-Buh_q0Vj.js.map} +1 -1
- package/studio/web/assets/{index-HGL4WHdb.js → index-0hXeECkS.js} +6 -6
- package/studio/web/assets/{index-HGL4WHdb.js.map → index-0hXeECkS.js.map} +1 -1
- package/studio/web/assets/{index.lazy-Dk5j1AoQ.js → index.lazy-B00B7VBT.js} +4 -4
- package/studio/web/assets/{index.lazy-Dk5j1AoQ.js.map → index.lazy-B00B7VBT.js.map} +1 -1
- package/studio/web/assets/{profile-7bu_CF1f.js → profile-Cg2wwYPn.js} +2 -2
- package/studio/web/assets/{profile-7bu_CF1f.js.map → profile-Cg2wwYPn.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-MfYm9F94.js → setComponentFile-DCaDIvyB.js} +2 -2
- package/studio/web/assets/{setComponentFile-MfYm9F94.js.map → setComponentFile-DCaDIvyB.js.map} +1 -1
- package/studio/web/assets/{setup-SKA4UhdE.js → setup-CAVcAQjK.js} +2 -2
- package/studio/web/assets/{setup-SKA4UhdE.js.map → setup-CAVcAQjK.js.map} +1 -1
- package/studio/web/assets/{status-DetVLUxR.js → status-BRXorNdD.js} +2 -2
- package/studio/web/assets/{status-DetVLUxR.js.map → status-BRXorNdD.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-DLeYARY2.js → swagger-ui-react-Dy1D62vO.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-DLeYARY2.js.map → swagger-ui-react-Dy1D62vO.js.map} +1 -1
- package/studio/web/assets/{tsMode-DGO-jIF7.js → tsMode-A8gbL74v.js} +2 -2
- package/studio/web/assets/{tsMode-DGO-jIF7.js.map → tsMode-A8gbL74v.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js → useEntityRestURL-cDodrVcQ.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js.map → useEntityRestURL-cDodrVcQ.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/errors/hdbError.ts +32 -0
- package/validation/configValidator.ts +23 -6
|
@@ -3,6 +3,15 @@ import { getConfigObj, getConfigValue, getConfigPath } from '../config/configUti
|
|
|
3
3
|
import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
|
|
4
4
|
import logger from '../utility/logging/harper_logger.ts';
|
|
5
5
|
import { broadcastDeployStart, broadcastDeployEnd } from './deployLifecycle.ts';
|
|
6
|
+
import type { CredentialReference, ResolvedCredential, ResolvedRegistryCredential } from './secretOperations.ts';
|
|
7
|
+
import {
|
|
8
|
+
GIT_CREDENTIAL_SOCKET_ENV,
|
|
9
|
+
startGitCredentialSession,
|
|
10
|
+
type GitCredentialSession,
|
|
11
|
+
type ResolvedGitCredential,
|
|
12
|
+
} from './gitCredentialServer.ts';
|
|
13
|
+
import { getSecretDecryptor } from '../resources/secretDecryptor.ts';
|
|
14
|
+
import { ENV_ENCRYPTED_PREFIX } from '../utility/envFile.ts';
|
|
6
15
|
|
|
7
16
|
import { basename, dirname, extname, join } from 'node:path';
|
|
8
17
|
import {
|
|
@@ -22,13 +31,14 @@ import {
|
|
|
22
31
|
import { spawn } from 'node:child_process';
|
|
23
32
|
import { tmpdir } from 'node:os';
|
|
24
33
|
import { randomUUID } from 'node:crypto';
|
|
25
|
-
import { createReadStream, existsSync
|
|
34
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
26
35
|
import { Readable } from 'node:stream';
|
|
27
36
|
import { pipeline } from 'node:stream/promises';
|
|
28
37
|
import { StringDecoder } from 'node:string_decoder';
|
|
29
38
|
|
|
30
39
|
import { extract } from 'tar-fs';
|
|
31
40
|
import gunzip from 'gunzip-maybe';
|
|
41
|
+
import semver from 'semver';
|
|
32
42
|
|
|
33
43
|
interface ApplicationConfig {
|
|
34
44
|
// define known config properties
|
|
@@ -38,10 +48,10 @@ interface ApplicationConfig {
|
|
|
38
48
|
timeout?: number;
|
|
39
49
|
allowInstallScripts?: boolean;
|
|
40
50
|
};
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
51
|
+
// Deploy credentials in reference form only — each entry names an hdb_secret row, never a token.
|
|
52
|
+
// Recorded by deploy_component so every (cold) install — reboot, new peer, rollback — re-resolves
|
|
53
|
+
// the credential from the store rather than needing it re-supplied.
|
|
54
|
+
credentials?: CredentialReference[];
|
|
45
55
|
// an application config can have other arbitrary properties
|
|
46
56
|
[key: string]: unknown;
|
|
47
57
|
}
|
|
@@ -78,18 +88,19 @@ export class InvalidInstallTimeoutError extends TypeError {
|
|
|
78
88
|
}
|
|
79
89
|
}
|
|
80
90
|
|
|
81
|
-
export class
|
|
82
|
-
constructor(applicationName: string,
|
|
91
|
+
export class InvalidCredentialsPropertyError extends TypeError {
|
|
92
|
+
constructor(applicationName: string, credentials: unknown) {
|
|
83
93
|
super(
|
|
84
|
-
`Invalid '
|
|
94
|
+
`Invalid 'credentials' property for application ${applicationName}: expected array, got ${typeof credentials}`
|
|
85
95
|
);
|
|
86
96
|
}
|
|
87
97
|
}
|
|
88
98
|
|
|
89
|
-
export class
|
|
99
|
+
export class InvalidCredentialEntryError extends TypeError {
|
|
90
100
|
constructor(applicationName: string) {
|
|
91
101
|
super(
|
|
92
|
-
`Invalid '
|
|
102
|
+
`Invalid 'credentials' entry for application ${applicationName}: expected a { registry, secret, scope? } ` +
|
|
103
|
+
`or { host, secret, username? } reference`
|
|
93
104
|
);
|
|
94
105
|
}
|
|
95
106
|
}
|
|
@@ -131,21 +142,28 @@ export function assertApplicationConfig(
|
|
|
131
142
|
);
|
|
132
143
|
}
|
|
133
144
|
}
|
|
134
|
-
if ('
|
|
135
|
-
const entries = applicationConfig.
|
|
145
|
+
if ('credentials' in applicationConfig && applicationConfig.credentials !== undefined) {
|
|
146
|
+
const entries = applicationConfig.credentials;
|
|
136
147
|
if (!Array.isArray(entries)) {
|
|
137
|
-
throw new
|
|
148
|
+
throw new InvalidCredentialsPropertyError(applicationName, entries);
|
|
138
149
|
}
|
|
139
150
|
for (const entry of entries) {
|
|
140
151
|
// Config carries references only — a literal `token` here would mean a plaintext credential
|
|
141
|
-
// was persisted to disk, which the deploy path is designed to prevent.
|
|
152
|
+
// was persisted to disk, which the deploy path is designed to prevent. An entry is npm
|
|
153
|
+
// registry auth (`registry`) XOR git host auth (`host`); anything else is not a credential we
|
|
154
|
+
// know how to resolve, so reject it rather than install without it. An entry carrying both
|
|
155
|
+
// discriminators, or a stray `token`, is rejected rather than coerced into a single kind.
|
|
156
|
+
const record = entry as any;
|
|
157
|
+
const hasRegistry = typeof record?.registry === 'string';
|
|
158
|
+
const hasHost = typeof record?.host === 'string';
|
|
142
159
|
if (
|
|
143
160
|
typeof entry !== 'object' ||
|
|
144
161
|
entry === null ||
|
|
145
|
-
|
|
146
|
-
typeof
|
|
162
|
+
hasRegistry === hasHost || // neither, or both
|
|
163
|
+
typeof record.secret !== 'string' ||
|
|
164
|
+
record.token !== undefined
|
|
147
165
|
) {
|
|
148
|
-
throw new
|
|
166
|
+
throw new InvalidCredentialEntryError(applicationName);
|
|
149
167
|
}
|
|
150
168
|
}
|
|
151
169
|
}
|
|
@@ -166,11 +184,231 @@ export function isSSHAuthFailure(stderr: string): boolean {
|
|
|
166
184
|
);
|
|
167
185
|
}
|
|
168
186
|
|
|
187
|
+
// Git-reference package identifier forms recognized below for the credentialed-clone path: the
|
|
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).
|
|
191
|
+
const GIT_URL_PREFIX = /^git\+(ssh|https?|file):\/\//i;
|
|
192
|
+
const GIT_PROTOCOL_PREFIX = /^git:\/\//i;
|
|
193
|
+
|
|
194
|
+
// Hosted-git shorthand prefixes: the same table derivePackageIdentifier implicitly relies on when it
|
|
195
|
+
// defaults a bare `owner/repo` to `github:owner/repo`, plus the other hosts npm's own shorthand spec
|
|
196
|
+
// recognizes. A bare `owner/repo` never reaches parseGitReference directly — the Application
|
|
197
|
+
// constructor always runs packageIdentifier through derivePackageIdentifier first, which turns it
|
|
198
|
+
// into `github:owner/repo` — so only the prefixed forms need handling here.
|
|
199
|
+
const HOSTED_GIT_HOSTS: Record<string, string> = {
|
|
200
|
+
github: 'github.com',
|
|
201
|
+
gitlab: 'gitlab.com',
|
|
202
|
+
bitbucket: 'bitbucket.org',
|
|
203
|
+
gist: 'gist.github.com',
|
|
204
|
+
};
|
|
205
|
+
const HOSTED_GIT_PREFIX = /^(github|gitlab|bitbucket|gist):(.+)$/i;
|
|
206
|
+
|
|
207
|
+
interface GitReference {
|
|
208
|
+
cloneUrl: string;
|
|
209
|
+
committish?: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
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.
|
|
218
|
+
*/
|
|
219
|
+
export function parseGitReference(packageIdentifier: string): GitReference | null {
|
|
220
|
+
const hashIndex = packageIdentifier.indexOf('#');
|
|
221
|
+
const committish = hashIndex === -1 ? undefined : packageIdentifier.slice(hashIndex + 1);
|
|
222
|
+
const spec = hashIndex === -1 ? packageIdentifier : packageIdentifier.slice(0, hashIndex);
|
|
223
|
+
if (GIT_URL_PREFIX.test(spec)) return { cloneUrl: spec.slice('git+'.length), committish };
|
|
224
|
+
if (GIT_PROTOCOL_PREFIX.test(spec)) return { cloneUrl: spec, committish };
|
|
225
|
+
const hostedMatch = HOSTED_GIT_PREFIX.exec(spec);
|
|
226
|
+
if (hostedMatch) {
|
|
227
|
+
const [, prefix, path] = hostedMatch;
|
|
228
|
+
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 };
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const NEUTRALIZED_LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepack', 'prepare'];
|
|
239
|
+
|
|
240
|
+
// npm's hosted-git-info convention (documented in #1799's own worked example, `github:my-org/my-app#semver:v1.2.3`)
|
|
241
|
+
// for a committish that names a semver range instead of a literal ref. `git checkout` has no notion
|
|
242
|
+
// of this syntax, so it must be resolved to a concrete tag before checkout.
|
|
243
|
+
const SEMVER_COMMITTISH_PREFIX = /^semver:/i;
|
|
244
|
+
|
|
245
|
+
// Matches npm's own git-tag-to-version extraction (@npmcli/git's lines-to-revs.js): a trailing
|
|
246
|
+
// `1.2.3`-shaped suffix, optionally `v`-prefixed, with anything ahead of it ignored — so a tag like
|
|
247
|
+
// `release-v1.2.3` resolves the same way npm's own git-dependency installer treats it.
|
|
248
|
+
const TAG_VERSION_SUFFIX = /v?(\d+\.\d+\.\d+(?:[-+].+)?)$/;
|
|
249
|
+
|
|
250
|
+
// A conservative safe-charset check on the FULL tag name — not just the version-shaped suffix
|
|
251
|
+
// TAG_VERSION_SUFFIX matches. git's own ref-name rules (`check-ref-format`) permit shell
|
|
252
|
+
// metacharacters like `$`, backticks, `;`, `&`, `|`, `(`, `)` in a tag name; a prefix ahead of the
|
|
253
|
+
// matched suffix (e.g. the `release-` in `release-v1.2.3`) is otherwise unconstrained. The resolved
|
|
254
|
+
// name is later checked out via `nonInteractiveSpawn`, which runs through a shell with no argument
|
|
255
|
+
// escaping, so a tag such as `$(id)v1.2.3` in the cloned repo would otherwise execute on checkout.
|
|
256
|
+
// A tag failing this check is excluded from resolution entirely rather than sanitized or escaped —
|
|
257
|
+
// this only has to reject shell metacharacters, not accept every ref git itself would allow.
|
|
258
|
+
const SAFE_TAG_NAME = /^[\w][\w.-]*$/;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Resolves a `semver:<range>` committish (e.g. `semver:v1.2.3`, `semver:^1.2.3`) against the tags of
|
|
262
|
+
* the given clone to a concrete, unambiguous tag ref. Returns the committish unchanged if it isn't a
|
|
263
|
+
* semver-range committish.
|
|
264
|
+
*/
|
|
265
|
+
async function resolveCommittish(application: Application, committish: string, cloneDir: string): Promise<string> {
|
|
266
|
+
if (!SEMVER_COMMITTISH_PREFIX.test(committish)) return committish;
|
|
267
|
+
// npm's own package-arg parser URL-decodes the value after `semver:` (a range containing `^`/`~`
|
|
268
|
+
// 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
|
+
}
|
|
276
|
+
|
|
277
|
+
// `git tag --list` rather than `for-each-ref --format=...`: nonInteractiveSpawn runs through a
|
|
278
|
+
// shell, and a `%(...)` format string is unsafe to pass through one.
|
|
279
|
+
const { code, stdout, stderr } = await nonInteractiveSpawn(application.name, 'git', ['tag', '--list'], cloneDir);
|
|
280
|
+
if (code !== 0) {
|
|
281
|
+
throw new Error(`Failed to list tags to resolve '${committish}' for ${application.packageIdentifier}: ${stderr}`);
|
|
282
|
+
}
|
|
283
|
+
const tags = stdout
|
|
284
|
+
.split('\n')
|
|
285
|
+
.map((tag) => tag.trim())
|
|
286
|
+
.filter(Boolean);
|
|
287
|
+
|
|
288
|
+
// A tag can be a bare version (`v1.2.3`) or carry a prefix ahead of one (`release-v1.2.3`); only
|
|
289
|
+
// the trailing version-shaped suffix is evaluated against the range, but the ORIGINAL tag name is
|
|
290
|
+
// what gets checked out.
|
|
291
|
+
const versionToTag = new Map<string, string>();
|
|
292
|
+
for (const tag of tags) {
|
|
293
|
+
if (!SAFE_TAG_NAME.test(tag)) continue;
|
|
294
|
+
const match = tag.match(TAG_VERSION_SUFFIX);
|
|
295
|
+
const version = match && semver.valid(match[1], { loose: true });
|
|
296
|
+
if (version) versionToTag.set(semver.clean(match[1], { loose: true }) as string, tag);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const resolvedVersion = semver.maxSatisfying([...versionToTag.keys()], range, { loose: true });
|
|
300
|
+
if (!resolvedVersion) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`Failed to resolve '${committish}' for ${application.packageIdentifier}: no tag satisfies range '${range}'. ` +
|
|
303
|
+
(tags.length ? `Tags found: ${tags.join(', ')}` : 'No tags were found in the repository.')
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
// refs/tags/<name> rather than the bare name: an unqualified `git checkout <name>` resolves to a
|
|
307
|
+
// same-named branch first if one exists, which would silently package the wrong commit.
|
|
308
|
+
return `refs/tags/${versionToTag.get(resolvedVersion)}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Clones a git reference ourselves — with the credential session's env, so the clone itself can
|
|
313
|
+
* still authenticate — and packs the checkout with its lifecycle scripts stripped, instead of
|
|
314
|
+
* letting `npm pack <git-url>` clone and pack it directly.
|
|
315
|
+
*
|
|
316
|
+
* `--ignore-scripts` is not a reliable suppression for a git source's own `prepare` script: pacote's
|
|
317
|
+
* DirFetcher runs it unconditionally on npm versions before 11.0.0 (the
|
|
318
|
+
* `if (this.opts.ignoreScripts) return` guard was only added upstream in npm 11) — which is exactly
|
|
319
|
+
* what Node 22's bundled npm (10.9.x) ships. A credentialed clone can't depend on which npm version
|
|
320
|
+
* happens to be on PATH, since a script running while the credential socket is reachable is exactly
|
|
321
|
+
* what this feature exists to prevent — so the script is removed from the checkout before packing,
|
|
322
|
+
* which works regardless of npm version.
|
|
323
|
+
*/
|
|
324
|
+
async function packGitReferenceWithoutScripts(
|
|
325
|
+
application: Application,
|
|
326
|
+
gitRef: GitReference,
|
|
327
|
+
parentDirPath: string
|
|
328
|
+
): Promise<string> {
|
|
329
|
+
const cloneDir = await mkdtemp(join(tmpdir(), 'harper-git-clone-'));
|
|
330
|
+
try {
|
|
331
|
+
const { code: cloneCode, stderr: cloneStderr } = await nonInteractiveSpawn(
|
|
332
|
+
application.name,
|
|
333
|
+
'git',
|
|
334
|
+
['clone', '--quiet', gitRef.cloneUrl, cloneDir],
|
|
335
|
+
parentDirPath,
|
|
336
|
+
undefined,
|
|
337
|
+
undefined,
|
|
338
|
+
undefined,
|
|
339
|
+
application.gitCredentialEnv
|
|
340
|
+
);
|
|
341
|
+
if (cloneCode !== 0) {
|
|
342
|
+
if (isSSHAuthFailure(cloneStderr)) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`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.`,
|
|
345
|
+
{ cause: new Error(cloneStderr) }
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
throw new Error(`Failed to clone package ${application.packageIdentifier}: ${cloneStderr}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (gitRef.committish) {
|
|
352
|
+
const committish = await resolveCommittish(application, gitRef.committish, cloneDir);
|
|
353
|
+
const { code: checkoutCode, stderr: checkoutStderr } = await nonInteractiveSpawn(
|
|
354
|
+
application.name,
|
|
355
|
+
'git',
|
|
356
|
+
['checkout', '--quiet', committish],
|
|
357
|
+
cloneDir
|
|
358
|
+
);
|
|
359
|
+
if (checkoutCode !== 0) {
|
|
360
|
+
throw new Error(`Failed to check out '${committish}' for ${application.packageIdentifier}: ${checkoutStderr}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Strip the checkout's own lifecycle scripts before packing — the mechanism above only
|
|
365
|
+
// suppresses npm's git-clone behavior; a plain `npm pack <local-dir>` still runs `prepare`
|
|
366
|
+
// unless it's gone from the manifest.
|
|
367
|
+
const manifestPath = join(cloneDir, 'package.json');
|
|
368
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
369
|
+
if (manifest.scripts) {
|
|
370
|
+
for (const scriptName of NEUTRALIZED_LIFECYCLE_SCRIPTS) delete manifest.scripts[scriptName];
|
|
371
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
372
|
+
}
|
|
373
|
+
|
|
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) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
`Failed to parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
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 });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
169
403
|
// Hidden directory under the components root holding component versions renamed aside
|
|
170
404
|
// during a deploy swap (see extractApplication). The leading dot keeps
|
|
171
405
|
// loadComponentDirectories from loading its contents as components.
|
|
172
406
|
export const ASIDE_STAGING_DIR = '.deploy-aside';
|
|
173
407
|
|
|
408
|
+
// The credential helper git executes for a private git-reference deploy. It ships alongside this
|
|
409
|
+
// module (both in source and in dist), holds no secret, and is inert without a live session.
|
|
410
|
+
export const GIT_CREDENTIAL_HELPER_PATH = join(__dirname, 'gitCredentialHelper.js');
|
|
411
|
+
|
|
174
412
|
/**
|
|
175
413
|
* Extract an application given payload (content of the application) or package (npm-compatible identifier to the application).
|
|
176
414
|
*
|
|
@@ -244,39 +482,72 @@ export async function extractApplication(application: Application) {
|
|
|
244
482
|
}
|
|
245
483
|
}
|
|
246
484
|
} else {
|
|
247
|
-
// `npm pack --json` writes a JSON array describing the packed tarball(s).
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
|
|
485
|
+
// `npm pack --json` writes a JSON array describing the packed tarball(s). This is also the
|
|
486
|
+
// spawn that clones a git-reference package, so it is the only one given the git credential
|
|
487
|
+
// environment.
|
|
488
|
+
//
|
|
489
|
+
// Packing a git reference is not just a download: npm clones the repo and, if its manifest
|
|
490
|
+
// 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;
|
|
497
|
+
// `--ignore-scripts` alone isn't a reliable way to enforce that: pacote's DirFetcher runs a
|
|
498
|
+
// git source's `prepare` unconditionally on npm versions before 11.0.0 (see
|
|
499
|
+
// packGitReferenceWithoutScripts), which is exactly what Node 22's bundled npm ships. For a
|
|
500
|
+
// recognized git-reference identifier, clone and pack it ourselves with scripts stripped
|
|
501
|
+
// instead, sidestepping that npm code path entirely.
|
|
502
|
+
const gitRef = scriptsDisallowed ? parseGitReference(application.packageIdentifier) : null;
|
|
503
|
+
|
|
504
|
+
if (gitRef) {
|
|
505
|
+
tarballPath = await packGitReferenceWithoutScripts(application, gitRef, parentDirPath);
|
|
506
|
+
} else {
|
|
507
|
+
const packArgs = ['pack', '--json', application.packageIdentifier];
|
|
508
|
+
if (scriptsDisallowed) {
|
|
509
|
+
packArgs.push('--ignore-scripts');
|
|
510
|
+
} else if (application.gitCredentialEnv) {
|
|
511
|
+
application.logger.warn(
|
|
512
|
+
`Deploying ${application.name} from a git reference with install scripts enabled: the repository's ` +
|
|
513
|
+
`prepare/build scripts and its dependencies' install scripts run on this node during the clone and ` +
|
|
514
|
+
`can read the git credential. Unset install_allow_scripts to keep the credential out of their reach.`
|
|
515
|
+
);
|
|
516
|
+
}
|
|
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) {
|
|
259
541
|
throw new Error(
|
|
260
|
-
`Failed to
|
|
261
|
-
{ cause: new Error(stderr) }
|
|
542
|
+
`Failed to parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`
|
|
262
543
|
);
|
|
263
544
|
}
|
|
264
|
-
|
|
265
|
-
|
|
545
|
+
if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') {
|
|
546
|
+
throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
|
|
547
|
+
}
|
|
266
548
|
|
|
267
|
-
|
|
268
|
-
try {
|
|
269
|
-
packResult = JSON.parse(stdout.slice(stdout.indexOf('[')));
|
|
270
|
-
} catch (err) {
|
|
271
|
-
throw new Error(
|
|
272
|
-
`Failed to parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') {
|
|
276
|
-
throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
|
|
549
|
+
tarballPath = join(parentDirPath, packResult[0].filename);
|
|
277
550
|
}
|
|
278
|
-
|
|
279
|
-
tarballPath = join(parentDirPath, packResult[0].filename);
|
|
280
551
|
shouldDeleteTarball = true;
|
|
281
552
|
tarball = createReadStream(tarballPath);
|
|
282
553
|
}
|
|
@@ -551,7 +822,9 @@ interface ApplicationOptions {
|
|
|
551
822
|
packageIdentifier?: string;
|
|
552
823
|
install?: { command?: string; timeout?: number; allowInstallScripts?: boolean };
|
|
553
824
|
onInstallLine?: OnInstallLine;
|
|
554
|
-
|
|
825
|
+
// Deploy credentials already resolved to literal tokens, of any kind; partitioned by the
|
|
826
|
+
// constructor into the npm and git halves, which are injected by entirely different mechanisms.
|
|
827
|
+
credentials?: ResolvedCredential[];
|
|
555
828
|
}
|
|
556
829
|
|
|
557
830
|
export class Application {
|
|
@@ -563,21 +836,39 @@ export class Application {
|
|
|
563
836
|
dirPath: string;
|
|
564
837
|
logger: Logger;
|
|
565
838
|
packageManagerPrefix: string; // can be used to configure a package manager prefix, specifically "sfw".
|
|
566
|
-
// Transient registry
|
|
567
|
-
// per-deploy `.npmrc`; it is never persisted to config,
|
|
568
|
-
|
|
839
|
+
// Transient registry credentials provided by a deploy, already resolved to literal tokens. The
|
|
840
|
+
// token is held only in memory and a per-deploy `.npmrc`; it is never persisted to config,
|
|
841
|
+
// hdb_deployment, or replicated.
|
|
842
|
+
registryCredentials?: ResolvedRegistryCredential[];
|
|
843
|
+
// Transient git-host credentials, likewise resolved to literal tokens and held only in memory:
|
|
844
|
+
// they are served to git over a per-deploy socket (gitCredentialServer.ts), never written anywhere.
|
|
845
|
+
gitCredentials?: ResolvedGitCredential[];
|
|
569
846
|
// Path to the per-deploy `.npmrc`, set by writeTransientNpmrc() during prepareApplication and
|
|
570
|
-
// passed to the spawn calls; undefined when no registry
|
|
847
|
+
// passed to the spawn calls; undefined when no registry credentials were provided.
|
|
571
848
|
npmUserconfigPath?: string;
|
|
572
849
|
#npmrcTempDir?: string;
|
|
850
|
+
#gitCredentialSession?: GitCredentialSession;
|
|
573
851
|
|
|
574
|
-
constructor({ name, payload, packageIdentifier, install, onInstallLine,
|
|
852
|
+
constructor({ name, payload, packageIdentifier, install, onInstallLine, credentials }: ApplicationOptions) {
|
|
575
853
|
this.name = name;
|
|
576
854
|
this.payload = payload;
|
|
577
855
|
this.packageIdentifier = packageIdentifier && derivePackageIdentifier(packageIdentifier);
|
|
578
856
|
this.install = install;
|
|
579
857
|
this.onInstallLine = onInstallLine;
|
|
580
|
-
|
|
858
|
+
// Split by kind: registry credentials go into the transient .npmrc, git credentials into the
|
|
859
|
+
// credential socket. An entry belongs to exactly one of them (the op schema is an xor).
|
|
860
|
+
// secretOperations owns the same predicate, but it is only ever imported from here lazily (it
|
|
861
|
+
// pulls in the datastore), so this stays a local check rather than a boot-time import.
|
|
862
|
+
if (credentials?.length) {
|
|
863
|
+
const registryCredentials = credentials.filter(
|
|
864
|
+
(entry): entry is ResolvedRegistryCredential => (entry as any).registry !== undefined
|
|
865
|
+
);
|
|
866
|
+
const gitCredentials = credentials.filter(
|
|
867
|
+
(entry): entry is ResolvedGitCredential => (entry as any).registry === undefined
|
|
868
|
+
);
|
|
869
|
+
if (registryCredentials.length) this.registryCredentials = registryCredentials;
|
|
870
|
+
if (gitCredentials.length) this.gitCredentials = gitCredentials;
|
|
871
|
+
}
|
|
581
872
|
const componentsRoot = getConfigPath(CONFIG_PARAMS.COMPONENTSROOT);
|
|
582
873
|
if (!componentsRoot) throw new Error('componentsRoot is not configured');
|
|
583
874
|
this.dirPath = join(componentsRoot, name);
|
|
@@ -586,7 +877,8 @@ export class Application {
|
|
|
586
877
|
}
|
|
587
878
|
|
|
588
879
|
// Write the transient `.npmrc` into a fresh 0700 temp dir (file mode 0600) and record its path
|
|
589
|
-
// so the deploy's npm spawns authenticate against the private registry. No-op without registry
|
|
880
|
+
// so the deploy's npm spawns authenticate against the private registry. No-op without registry
|
|
881
|
+
// credentials.
|
|
590
882
|
//
|
|
591
883
|
// Because `nonInteractiveSpawn` points npm at this single file (replacing any inherited
|
|
592
884
|
// npm_config_userconfig), prepend the contents of an already-configured userconfig — e.g. a
|
|
@@ -594,7 +886,7 @@ export class Application {
|
|
|
594
886
|
// survive. The transient auth is appended last so it wins on conflict (npm honors the last
|
|
595
887
|
// value for a given key).
|
|
596
888
|
async writeTransientNpmrc(): Promise<void> {
|
|
597
|
-
if (!this.
|
|
889
|
+
if (!this.registryCredentials?.length) return;
|
|
598
890
|
// Defensive: if called more than once, remove the prior temp dir first so it isn't leaked.
|
|
599
891
|
if (this.#npmrcTempDir) await this.cleanupTransientNpmrc();
|
|
600
892
|
this.#npmrcTempDir = await mkdtemp(join(tmpdir(), 'harper-npmrc-'));
|
|
@@ -610,11 +902,42 @@ export class Application {
|
|
|
610
902
|
if (error?.code !== 'ENOENT') throw error;
|
|
611
903
|
}
|
|
612
904
|
}
|
|
613
|
-
content += buildNpmrcContent(this.
|
|
905
|
+
content += buildNpmrcContent(this.registryCredentials);
|
|
614
906
|
await writeFile(npmrcPath, content, { mode: 0o600 });
|
|
615
907
|
this.npmUserconfigPath = npmrcPath;
|
|
616
908
|
}
|
|
617
909
|
|
|
910
|
+
// Environment that lets git reach this deploy's credential socket. Applied ONLY to the spawn that
|
|
911
|
+
// clones the git reference (`npm pack`) — see prepareApplication.
|
|
912
|
+
get gitCredentialEnv(): Record<string, string> | undefined {
|
|
913
|
+
return this.#gitCredentialSession?.env;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Start serving this deploy's git-host credentials from memory. No-op without git credentials.
|
|
917
|
+
async startGitCredentialSession(): Promise<void> {
|
|
918
|
+
if (!this.gitCredentials?.length) return;
|
|
919
|
+
if (this.#gitCredentialSession) await this.cleanupGitCredentialSession();
|
|
920
|
+
this.#gitCredentialSession = await startGitCredentialSession(this.gitCredentials, GIT_CREDENTIAL_HELPER_PATH);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Tear the socket down as soon as the clone is done, so nothing later in the deploy — including
|
|
924
|
+
// the component's own install scripts — can still ask for the credential.
|
|
925
|
+
async cleanupGitCredentialSession(): Promise<void> {
|
|
926
|
+
const session = this.#gitCredentialSession;
|
|
927
|
+
if (!session) return;
|
|
928
|
+
this.#gitCredentialSession = undefined;
|
|
929
|
+
try {
|
|
930
|
+
await session.close();
|
|
931
|
+
} catch (error) {
|
|
932
|
+
// Called from prepareApplication's finally; a throw here would mask the deploy's own error.
|
|
933
|
+
this.logger.warn(`Failed to close git credential session:`, error);
|
|
934
|
+
} finally {
|
|
935
|
+
// Drop the in-memory tokens too, so they can't surface in a later heap dump or error
|
|
936
|
+
// serialization of this Application instance.
|
|
937
|
+
this.gitCredentials = undefined;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
618
941
|
// Remove the transient `.npmrc` (and its temp dir) once the deploy's npm work is done.
|
|
619
942
|
async cleanupTransientNpmrc(): Promise<void> {
|
|
620
943
|
if (!this.#npmrcTempDir) return;
|
|
@@ -629,7 +952,7 @@ export class Application {
|
|
|
629
952
|
this.npmUserconfigPath = undefined;
|
|
630
953
|
// Drop the in-memory token array too, so it can't surface in a later heap dump or error
|
|
631
954
|
// serialization of this Application instance.
|
|
632
|
-
this.
|
|
955
|
+
this.registryCredentials = undefined;
|
|
633
956
|
}
|
|
634
957
|
}
|
|
635
958
|
}
|
|
@@ -674,7 +997,16 @@ export async function prepareApplication(application: Application) {
|
|
|
674
997
|
// Materialize the per-deploy `.npmrc` before extraction so both `npm pack` (extract) and
|
|
675
998
|
// `npm install` authenticate against the private registry; always remove it afterward.
|
|
676
999
|
await application.writeTransientNpmrc();
|
|
677
|
-
|
|
1000
|
+
try {
|
|
1001
|
+
// The git credential socket only has to be up for extraction — that is where npm resolves and
|
|
1002
|
+
// clones a git-reference package. Closing it before installApplication means the credential is
|
|
1003
|
+
// already gone by the time the component's dependency tree (and any install script it is
|
|
1004
|
+
// allowed to run) executes.
|
|
1005
|
+
await application.startGitCredentialSession();
|
|
1006
|
+
await extractApplication(application);
|
|
1007
|
+
} finally {
|
|
1008
|
+
await application.cleanupGitCredentialSession();
|
|
1009
|
+
}
|
|
678
1010
|
await installApplication(application);
|
|
679
1011
|
} finally {
|
|
680
1012
|
await application.cleanupTransientNpmrc();
|
|
@@ -737,20 +1069,19 @@ export async function installApplications() {
|
|
|
737
1069
|
// This will throw if the config is invalid
|
|
738
1070
|
assertApplicationConfig(name, applicationConfig);
|
|
739
1071
|
|
|
740
|
-
// Resolve any
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
let
|
|
746
|
-
if (applicationConfig.
|
|
1072
|
+
// Resolve any credential references from the store so a cold install (fresh node, wiped
|
|
1073
|
+
// components dir, new peer that never installed) can authenticate without the token being
|
|
1074
|
+
// re-supplied. Best-effort: if custody isn't available yet or a referenced secret is
|
|
1075
|
+
// missing, log and install without it (a truly private package then fails in npm with its
|
|
1076
|
+
// own error) rather than blocking boot.
|
|
1077
|
+
let credentials: ResolvedCredential[] | undefined;
|
|
1078
|
+
if (applicationConfig.credentials?.length) {
|
|
747
1079
|
try {
|
|
748
|
-
const {
|
|
749
|
-
|
|
750
|
-
RegistryAuthEntry[] | undefined;
|
|
1080
|
+
const { resolveCredentials } = await import('./secretOperations.ts');
|
|
1081
|
+
credentials = await resolveCredentials(applicationConfig.credentials, name);
|
|
751
1082
|
} catch (error) {
|
|
752
1083
|
logger.warn?.(
|
|
753
|
-
`Could not resolve
|
|
1084
|
+
`Could not resolve credentials for application ${name} at install time: ${(error as Error).message}`
|
|
754
1085
|
);
|
|
755
1086
|
}
|
|
756
1087
|
}
|
|
@@ -759,7 +1090,7 @@ export async function installApplications() {
|
|
|
759
1090
|
name,
|
|
760
1091
|
packageIdentifier: applicationConfig.package,
|
|
761
1092
|
install: applicationConfig.install,
|
|
762
|
-
|
|
1093
|
+
credentials,
|
|
763
1094
|
});
|
|
764
1095
|
|
|
765
1096
|
// Lock check: only install if not already installed with matching configuration
|
|
@@ -788,22 +1119,126 @@ export async function installApplications() {
|
|
|
788
1119
|
await writeFile(harperApplicationLockPath, JSON.stringify(harperApplicationLock, null, 2), 'utf8');
|
|
789
1120
|
}
|
|
790
1121
|
|
|
791
|
-
|
|
1122
|
+
/**
|
|
1123
|
+
* Rewrite every occurrence of `sshDir` in an ssh `config` file's contents to `tempDir`, matching
|
|
1124
|
+
* either slash direction per path segment (and case-insensitively on `win32`) rather than relying
|
|
1125
|
+
* on the two strings being byte-identical.
|
|
1126
|
+
*
|
|
1127
|
+
* A plain string substitution (`sshConfig.split(sshDir).join(tempDir)`) doesn't hold
|
|
1128
|
+
* cross-platform: ssh config files are frequently forward-slash even on Windows, while `sshDir`
|
|
1129
|
+
* (built via `path.join`) is backslash there, so the split would silently never match; Windows
|
|
1130
|
+
* paths are also case-insensitive. `sshDir` is split into segments on either separator *before*
|
|
1131
|
+
* escaping, so escaping a regex-special character within a segment (e.g. a literal paren in a
|
|
1132
|
+
* directory name) can't interact with the separator substitution. A trailing lookahead keeps
|
|
1133
|
+
* `sshDir` from partial-matching a sibling directory whose name happens to start with it (e.g.
|
|
1134
|
+
* `.../ssh` vs `.../sshhh`).
|
|
1135
|
+
*
|
|
1136
|
+
* Exported only so unit tests can pin the Windows-path behavior (mixed slash direction, case
|
|
1137
|
+
* insensitivity) directly via the `platform` override, without needing to run on Windows.
|
|
1138
|
+
*/
|
|
1139
|
+
export function rewriteSshConfigPaths(
|
|
1140
|
+
sshConfig: string,
|
|
1141
|
+
sshDir: string,
|
|
1142
|
+
tempDir: string,
|
|
1143
|
+
platform: string = process.platform
|
|
1144
|
+
): string {
|
|
1145
|
+
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1146
|
+
const sepClass = '[\\\\/]';
|
|
1147
|
+
const sshDirPattern = sshDir.split(/[\\/]/).map(escapeRegExp).join(sepClass) + `(?=${sepClass}|$)`;
|
|
1148
|
+
const sshDirRegex = new RegExp(sshDirPattern, platform === 'win32' ? 'gi' : 'g');
|
|
1149
|
+
const normalizedTempDir = platform === 'win32' ? tempDir.replace(/\\/g, '/') : tempDir;
|
|
1150
|
+
// Function replacer: a string replacer would treat `$` sequences in normalizedTempDir
|
|
1151
|
+
// (e.g. `$&`, `$1`) as replacement patterns instead of literal characters.
|
|
1152
|
+
return sshConfig.replace(sshDirRegex, () => normalizedTempDir);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Materialize the SSH deploy keys for the lifetime of a single git-over-SSH spawn.
|
|
1157
|
+
*
|
|
1158
|
+
* Keys are sealed at rest as `enc:v1:` envelopes (Harper Pro's `add_ssh_key`), but ssh needs a key
|
|
1159
|
+
* *file*, so each key is decrypted into a fresh 0700 temp dir as a 0600 file and the ssh config is
|
|
1160
|
+
* copied with its `IdentityFile` paths repointed at the transient copies. The caller removes the
|
|
1161
|
+
* dir as soon as the spawn settles, so the plaintext never outlives the git invocation. Legacy
|
|
1162
|
+
* plaintext keys (written before sealing, or on a node with no custody) are copied through
|
|
1163
|
+
* unchanged, so this is a no-op for them beyond the temp dir.
|
|
1164
|
+
*
|
|
1165
|
+
* A key that cannot be decrypted — no custody registered on this node, an envelope sealed under a
|
|
1166
|
+
* different cluster key, or a tampered envelope — is logged and skipped rather than failing the
|
|
1167
|
+
* spawn: a deploy that doesn't need that key is unaffected, and one that does fails with the usual
|
|
1168
|
+
* SSH auth error (see `isSSHAuthFailure`). No log or error message here carries key material or
|
|
1169
|
+
* the envelope.
|
|
1170
|
+
*
|
|
1171
|
+
* @returns The `GIT_SSH_COMMAND` to use plus its cleanup, or undefined when no keys are configured.
|
|
1172
|
+
*/
|
|
1173
|
+
export async function materializeGitSSH(): Promise<{ command: string; cleanup: () => Promise<void> } | undefined> {
|
|
792
1174
|
const rootDir = getConfigValue(CONFIG_PARAMS.ROOTPATH);
|
|
1175
|
+
if (!rootDir) return; // config not initialized (e.g. an install-time spawn) — no ssh dir to read
|
|
793
1176
|
const sshDir = join(rootDir, 'ssh');
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1177
|
+
// `withFileTypes` so a stray subdirectory in the ssh dir (e.g. one named `foo.key`) is filtered
|
|
1178
|
+
// out here rather than reaching `readFile` below and throwing EISDIR, which would abort the
|
|
1179
|
+
// whole spawn instead of just skipping that one entry.
|
|
1180
|
+
const sshDirEntries = await readdir(sshDir, { withFileTypes: true }).catch(() => undefined);
|
|
1181
|
+
if (!sshDirEntries) return; // no ssh dir on this node
|
|
1182
|
+
const keyFiles = sshDirEntries
|
|
1183
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.key'))
|
|
1184
|
+
.map((entry) => entry.name);
|
|
1185
|
+
if (keyFiles.length === 0) return;
|
|
1186
|
+
|
|
1187
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'harper-ssh-'));
|
|
1188
|
+
const cleanup = async () => {
|
|
1189
|
+
try {
|
|
1190
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
// never mask the caller's error (this runs from a finally) — a leaked temp dir is the
|
|
1193
|
+
// lesser failure, and it holds only 0600 files in a 0700 dir
|
|
1194
|
+
logger.warn?.(`Failed to remove transient ssh dir ${tempDir}:`, error);
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
try {
|
|
1199
|
+
const decryptor = getSecretDecryptor();
|
|
1200
|
+
for (const keyFile of keyFiles) {
|
|
1201
|
+
let storedKey: string;
|
|
1202
|
+
try {
|
|
1203
|
+
storedKey = await readFile(join(sshDir, keyFile), 'utf8');
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
// Same fail-open policy as a decrypt failure below: a key that can't even be read
|
|
1206
|
+
// (permission drift, deleted mid-scan by a concurrent add_ssh_key/rotation, a
|
|
1207
|
+
// transient EIO) should drop that one key, not abort a spawn that may not need it.
|
|
1208
|
+
logger.error?.(`Failed to read SSH key ${keyFile}: ${(error as Error).message}; skipping it`);
|
|
1209
|
+
continue;
|
|
798
1210
|
}
|
|
1211
|
+
let keyMaterial = storedKey;
|
|
1212
|
+
if (storedKey.startsWith(ENV_ENCRYPTED_PREFIX)) {
|
|
1213
|
+
if (!decryptor) {
|
|
1214
|
+
logger.error?.(
|
|
1215
|
+
`SSH key ${keyFile} is encrypted but no secret custody is registered on this node; skipping it`
|
|
1216
|
+
);
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
try {
|
|
1220
|
+
keyMaterial = decryptor(storedKey);
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
logger.error?.(`Failed to decrypt SSH key ${keyFile}: ${(error as Error).message}; skipping it`);
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
await writeFile(join(tempDir, keyFile), keyMaterial, { mode: 0o600 });
|
|
799
1227
|
}
|
|
1228
|
+
// The config's `IdentityFile` lines are absolute paths into the durable ssh dir; repoint
|
|
1229
|
+
// them at the transient copies. known_hosts holds no secrets and stays where it is.
|
|
1230
|
+
const sshConfig = await readFile(join(sshDir, 'config'), 'utf8').catch(() => '');
|
|
1231
|
+
const rewrittenConfig = rewriteSshConfigPaths(sshConfig, sshDir, tempDir);
|
|
1232
|
+
await writeFile(join(tempDir, 'config'), rewrittenConfig, { mode: 0o600 });
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
await cleanup();
|
|
1235
|
+
throw error;
|
|
800
1236
|
}
|
|
801
|
-
}
|
|
802
1237
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1238
|
+
return {
|
|
1239
|
+
command: `ssh -F ${join(tempDir, 'config')} -o UserKnownHostsFile=${join(sshDir, 'known_hosts')}`,
|
|
1240
|
+
cleanup,
|
|
1241
|
+
};
|
|
807
1242
|
}
|
|
808
1243
|
|
|
809
1244
|
// Normalize a registry to a full URL with a scheme and trailing slash, e.g.
|
|
@@ -817,16 +1252,16 @@ function normalizeRegistryUrl(registry: string): string {
|
|
|
817
1252
|
return url;
|
|
818
1253
|
}
|
|
819
1254
|
|
|
820
|
-
// Build the contents of a transient `.npmrc` from registry
|
|
1255
|
+
// Build the contents of a transient `.npmrc` from resolved registry credentials: an auth-token line keyed
|
|
821
1256
|
// by npm's registry auth key (scheme stripped, leading `//`, trailing `/`) plus a registry-routing
|
|
822
1257
|
// line. A scope routes only that `@scope` to the registry (`@scope:registry=…`); without a scope
|
|
823
1258
|
// the entry sets npm's default `registry=…` so an unscoped package spec (e.g. `npm:my-private-app`)
|
|
824
1259
|
// or its transitive deps actually resolve against this registry rather than the public default.
|
|
825
1260
|
// A scope-less entry therefore requires its registry to serve/proxy whatever npm needs to install;
|
|
826
1261
|
// with multiple scope-less entries npm's last-value-wins applies to the default `registry`.
|
|
827
|
-
export function buildNpmrcContent(
|
|
1262
|
+
export function buildNpmrcContent(registryCredentials: ResolvedRegistryCredential[]): string {
|
|
828
1263
|
const lines: string[] = [];
|
|
829
|
-
for (const { registry, token, scope } of
|
|
1264
|
+
for (const { registry, token, scope } of registryCredentials) {
|
|
830
1265
|
// Enforce the no-newline invariant at the injection point so it holds for every source. The
|
|
831
1266
|
// ops validator already rejects CR/LF in a literal `token`, but a token resolved from an
|
|
832
1267
|
// hdb_secret row bypasses that guard; without this a `\n` in a secret value would inject
|
|
@@ -896,14 +1331,49 @@ function createLineSplitter(onLine: (line: string) => void): {
|
|
|
896
1331
|
};
|
|
897
1332
|
}
|
|
898
1333
|
|
|
899
|
-
|
|
1334
|
+
/**
|
|
1335
|
+
* Run a command with the deploy's SSH key material materialized only for the duration of the
|
|
1336
|
+
* spawn. The keys are decrypted to a transient 0700 dir (see `materializeGitSSH`) and removed as
|
|
1337
|
+
* soon as the process settles — on success, failure, and timeout alike.
|
|
1338
|
+
*/
|
|
1339
|
+
export async function nonInteractiveSpawn(
|
|
900
1340
|
applicationName: string,
|
|
901
1341
|
command: string,
|
|
902
1342
|
args: string[],
|
|
903
1343
|
cwd: string,
|
|
904
1344
|
timeoutMs: number = 60 * 60 * 1000,
|
|
905
1345
|
onLine?: (stream: 'stdout' | 'stderr', line: string) => void,
|
|
906
|
-
npmUserconfigPath?: string
|
|
1346
|
+
npmUserconfigPath?: string,
|
|
1347
|
+
gitCredentialEnv?: Record<string, string>
|
|
1348
|
+
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
1349
|
+
const gitSSH = await materializeGitSSH();
|
|
1350
|
+
try {
|
|
1351
|
+
return await spawnWithEnv(
|
|
1352
|
+
applicationName,
|
|
1353
|
+
command,
|
|
1354
|
+
args,
|
|
1355
|
+
cwd,
|
|
1356
|
+
timeoutMs,
|
|
1357
|
+
onLine,
|
|
1358
|
+
npmUserconfigPath,
|
|
1359
|
+
gitSSH?.command,
|
|
1360
|
+
gitCredentialEnv
|
|
1361
|
+
);
|
|
1362
|
+
} finally {
|
|
1363
|
+
await gitSSH?.cleanup();
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function spawnWithEnv(
|
|
1368
|
+
applicationName: string,
|
|
1369
|
+
command: string,
|
|
1370
|
+
args: string[],
|
|
1371
|
+
cwd: string,
|
|
1372
|
+
timeoutMs: number,
|
|
1373
|
+
onLine: ((stream: 'stdout' | 'stderr', line: string) => void) | undefined,
|
|
1374
|
+
npmUserconfigPath: string | undefined,
|
|
1375
|
+
gitSSHCommand: string | undefined,
|
|
1376
|
+
gitCredentialEnv: Record<string, string> | undefined
|
|
907
1377
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
908
1378
|
return new Promise((resolve, reject) => {
|
|
909
1379
|
logger
|
|
@@ -912,11 +1382,22 @@ export function nonInteractiveSpawn(
|
|
|
912
1382
|
|
|
913
1383
|
const env = { ...process.env };
|
|
914
1384
|
|
|
915
|
-
const gitSSHCommand = getGitSSHCommand();
|
|
916
1385
|
if (gitSSHCommand) {
|
|
917
1386
|
env.GIT_SSH_COMMAND = gitSSHCommand;
|
|
918
1387
|
}
|
|
919
1388
|
|
|
1389
|
+
// The git credential channel is granted per spawn, and only to the one that clones the git
|
|
1390
|
+
// reference. Every other spawn — notably `npm install`, where a dependency's install script can
|
|
1391
|
+
// run — has it removed, so a transitive dependency cannot ask for a credential that was supplied
|
|
1392
|
+
// for the top-level repository. Only the socket variable is stripped rather than any inherited
|
|
1393
|
+
// GIT_ASKPASS/GIT_CONFIG_*: those may be the operator's own git auth, and without a socket to
|
|
1394
|
+
// reach, our helper answers nothing and is inert.
|
|
1395
|
+
if (gitCredentialEnv) {
|
|
1396
|
+
Object.assign(env, gitCredentialEnv);
|
|
1397
|
+
} else {
|
|
1398
|
+
delete env[GIT_CREDENTIAL_SOCKET_ENV];
|
|
1399
|
+
}
|
|
1400
|
+
|
|
920
1401
|
// A deploy carrying transient registry auth points npm at a per-deploy `.npmrc` so
|
|
921
1402
|
// `npm pack`/`install` can authenticate against a private registry without the token
|
|
922
1403
|
// ever touching disk durably, the package reference, config, or hdb_deployment.
|