@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,14 +3,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.Application = exports.ASIDE_STAGING_DIR = exports.
|
|
6
|
+
exports.Application = exports.GIT_CREDENTIAL_HELPER_PATH = exports.ASIDE_STAGING_DIR = exports.InvalidCredentialEntryError = exports.InvalidCredentialsPropertyError = exports.InvalidInstallTimeoutError = exports.InvalidInstallCommandError = exports.InvalidInstallPropertyError = exports.InvalidPackageIdentifierError = void 0;
|
|
7
7
|
exports.assertApplicationConfig = assertApplicationConfig;
|
|
8
8
|
exports.isSSHAuthFailure = isSSHAuthFailure;
|
|
9
|
+
exports.parseGitReference = parseGitReference;
|
|
9
10
|
exports.extractApplication = extractApplication;
|
|
10
11
|
exports.installApplication = installApplication;
|
|
11
12
|
exports.derivePackageIdentifier = derivePackageIdentifier;
|
|
12
13
|
exports.prepareApplication = prepareApplication;
|
|
13
14
|
exports.installApplications = installApplications;
|
|
15
|
+
exports.rewriteSshConfigPaths = rewriteSshConfigPaths;
|
|
16
|
+
exports.materializeGitSSH = materializeGitSSH;
|
|
14
17
|
exports.buildNpmrcContent = buildNpmrcContent;
|
|
15
18
|
exports.nonInteractiveSpawn = nonInteractiveSpawn;
|
|
16
19
|
exports.getEnvBuiltInComponents = getEnvBuiltInComponents;
|
|
@@ -18,6 +21,9 @@ const configUtils_ts_1 = require("../config/configUtils.js");
|
|
|
18
21
|
const hdbTerms_ts_1 = require("../utility/hdbTerms.js");
|
|
19
22
|
const harper_logger_ts_1 = __importDefault(require("../utility/logging/harper_logger.js"));
|
|
20
23
|
const deployLifecycle_ts_1 = require("./deployLifecycle.js");
|
|
24
|
+
const gitCredentialServer_ts_1 = require("./gitCredentialServer.js");
|
|
25
|
+
const secretDecryptor_ts_1 = require("../resources/secretDecryptor.js");
|
|
26
|
+
const envFile_ts_1 = require("../utility/envFile.js");
|
|
21
27
|
const node_path_1 = require("node:path");
|
|
22
28
|
const promises_1 = require("node:fs/promises");
|
|
23
29
|
const node_child_process_1 = require("node:child_process");
|
|
@@ -29,6 +35,7 @@ const promises_2 = require("node:stream/promises");
|
|
|
29
35
|
const node_string_decoder_1 = require("node:string_decoder");
|
|
30
36
|
const tar_fs_1 = require("tar-fs");
|
|
31
37
|
const gunzip_maybe_1 = __importDefault(require("gunzip-maybe"));
|
|
38
|
+
const semver_1 = __importDefault(require("semver"));
|
|
32
39
|
class InvalidPackageIdentifierError extends TypeError {
|
|
33
40
|
constructor(applicationName, packageIdentifier) {
|
|
34
41
|
super(`Invalid 'package' property for application ${applicationName}: expected string, got ${typeof packageIdentifier}`);
|
|
@@ -53,18 +60,19 @@ class InvalidInstallTimeoutError extends TypeError {
|
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
exports.InvalidInstallTimeoutError = InvalidInstallTimeoutError;
|
|
56
|
-
class
|
|
57
|
-
constructor(applicationName,
|
|
58
|
-
super(`Invalid '
|
|
63
|
+
class InvalidCredentialsPropertyError extends TypeError {
|
|
64
|
+
constructor(applicationName, credentials) {
|
|
65
|
+
super(`Invalid 'credentials' property for application ${applicationName}: expected array, got ${typeof credentials}`);
|
|
59
66
|
}
|
|
60
67
|
}
|
|
61
|
-
exports.
|
|
62
|
-
class
|
|
68
|
+
exports.InvalidCredentialsPropertyError = InvalidCredentialsPropertyError;
|
|
69
|
+
class InvalidCredentialEntryError extends TypeError {
|
|
63
70
|
constructor(applicationName) {
|
|
64
|
-
super(`Invalid '
|
|
71
|
+
super(`Invalid 'credentials' entry for application ${applicationName}: expected a { registry, secret, scope? } ` +
|
|
72
|
+
`or { host, secret, username? } reference`);
|
|
65
73
|
}
|
|
66
74
|
}
|
|
67
|
-
exports.
|
|
75
|
+
exports.InvalidCredentialEntryError = InvalidCredentialEntryError;
|
|
68
76
|
function assertApplicationConfig(applicationName, applicationConfig) {
|
|
69
77
|
if (typeof applicationConfig.package !== 'string') {
|
|
70
78
|
throw new InvalidPackageIdentifierError(applicationName, applicationConfig.package);
|
|
@@ -88,19 +96,26 @@ function assertApplicationConfig(applicationName, applicationConfig) {
|
|
|
88
96
|
})(`Invalid 'install.allowInstallScripts' property for application ${applicationName}: expected boolean, got ${typeof applicationConfig.install.allowInstallScripts}`);
|
|
89
97
|
}
|
|
90
98
|
}
|
|
91
|
-
if ('
|
|
92
|
-
const entries = applicationConfig.
|
|
99
|
+
if ('credentials' in applicationConfig && applicationConfig.credentials !== undefined) {
|
|
100
|
+
const entries = applicationConfig.credentials;
|
|
93
101
|
if (!Array.isArray(entries)) {
|
|
94
|
-
throw new
|
|
102
|
+
throw new InvalidCredentialsPropertyError(applicationName, entries);
|
|
95
103
|
}
|
|
96
104
|
for (const entry of entries) {
|
|
97
105
|
// Config carries references only — a literal `token` here would mean a plaintext credential
|
|
98
|
-
// was persisted to disk, which the deploy path is designed to prevent.
|
|
106
|
+
// was persisted to disk, which the deploy path is designed to prevent. An entry is npm
|
|
107
|
+
// registry auth (`registry`) XOR git host auth (`host`); anything else is not a credential we
|
|
108
|
+
// know how to resolve, so reject it rather than install without it. An entry carrying both
|
|
109
|
+
// discriminators, or a stray `token`, is rejected rather than coerced into a single kind.
|
|
110
|
+
const record = entry;
|
|
111
|
+
const hasRegistry = typeof record?.registry === 'string';
|
|
112
|
+
const hasHost = typeof record?.host === 'string';
|
|
99
113
|
if (typeof entry !== 'object' ||
|
|
100
114
|
entry === null ||
|
|
101
|
-
|
|
102
|
-
typeof
|
|
103
|
-
|
|
115
|
+
hasRegistry === hasHost || // neither, or both
|
|
116
|
+
typeof record.secret !== 'string' ||
|
|
117
|
+
record.token !== undefined) {
|
|
118
|
+
throw new InvalidCredentialEntryError(applicationName);
|
|
104
119
|
}
|
|
105
120
|
}
|
|
106
121
|
}
|
|
@@ -117,10 +132,184 @@ function isSSHAuthFailure(stderr) {
|
|
|
117
132
|
stderr.includes('No user exists for uid') ||
|
|
118
133
|
stderr.includes('Host key verification failed'));
|
|
119
134
|
}
|
|
135
|
+
// Git-reference package identifier forms recognized below for the credentialed-clone path: the
|
|
136
|
+
// npm git-url spec forms this repo's own derivePackageIdentifier can produce for a git host
|
|
137
|
+
// credential. An identifier that doesn't match falls back to `npm pack --ignore-scripts` (best
|
|
138
|
+
// effort, same as before this fix — not a regression for a form this can't safely reclone).
|
|
139
|
+
const GIT_URL_PREFIX = /^git\+(ssh|https?|file):\/\//i;
|
|
140
|
+
const GIT_PROTOCOL_PREFIX = /^git:\/\//i;
|
|
141
|
+
// Hosted-git shorthand prefixes: the same table derivePackageIdentifier implicitly relies on when it
|
|
142
|
+
// defaults a bare `owner/repo` to `github:owner/repo`, plus the other hosts npm's own shorthand spec
|
|
143
|
+
// recognizes. A bare `owner/repo` never reaches parseGitReference directly — the Application
|
|
144
|
+
// constructor always runs packageIdentifier through derivePackageIdentifier first, which turns it
|
|
145
|
+
// into `github:owner/repo` — so only the prefixed forms need handling here.
|
|
146
|
+
const HOSTED_GIT_HOSTS = {
|
|
147
|
+
github: 'github.com',
|
|
148
|
+
gitlab: 'gitlab.com',
|
|
149
|
+
bitbucket: 'bitbucket.org',
|
|
150
|
+
gist: 'gist.github.com',
|
|
151
|
+
};
|
|
152
|
+
const HOSTED_GIT_PREFIX = /^(github|gitlab|bitbucket|gist):(.+)$/i;
|
|
153
|
+
/**
|
|
154
|
+
* Parses a `git+ssh://…`/`git+https://…`/`git+http://…`/`git+file://…`/`git://…`, or hosted-git
|
|
155
|
+
* shorthand (`github:owner/repo`, `gitlab:owner/repo`, `bitbucket:owner/repo`, `gist:id`) package
|
|
156
|
+
* identifier into a plain clone URL and optional committish, without depending on npm's own git-spec
|
|
157
|
+
* parser (npm-package-arg/hosted-git-info aren't dependencies of this repo). Returns null for any
|
|
158
|
+
* other form.
|
|
159
|
+
*/
|
|
160
|
+
function parseGitReference(packageIdentifier) {
|
|
161
|
+
const hashIndex = packageIdentifier.indexOf('#');
|
|
162
|
+
const committish = hashIndex === -1 ? undefined : packageIdentifier.slice(hashIndex + 1);
|
|
163
|
+
const spec = hashIndex === -1 ? packageIdentifier : packageIdentifier.slice(0, hashIndex);
|
|
164
|
+
if (GIT_URL_PREFIX.test(spec))
|
|
165
|
+
return { cloneUrl: spec.slice('git+'.length), committish };
|
|
166
|
+
if (GIT_PROTOCOL_PREFIX.test(spec))
|
|
167
|
+
return { cloneUrl: spec, committish };
|
|
168
|
+
const hostedMatch = HOSTED_GIT_PREFIX.exec(spec);
|
|
169
|
+
if (hostedMatch) {
|
|
170
|
+
const [, prefix, path] = hostedMatch;
|
|
171
|
+
const host = HOSTED_GIT_HOSTS[prefix.toLowerCase()];
|
|
172
|
+
// A gist clone URL is keyed by id alone; an optional `owner/` in the shorthand (npm accepts
|
|
173
|
+
// `gist:[owner/]id`) has no place in the URL and is dropped.
|
|
174
|
+
const urlPath = prefix.toLowerCase() === 'gist' && path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path;
|
|
175
|
+
return { cloneUrl: `https://${host}/${urlPath}.git`, committish };
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const NEUTRALIZED_LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepack', 'prepare'];
|
|
180
|
+
// npm's hosted-git-info convention (documented in #1799's own worked example, `github:my-org/my-app#semver:v1.2.3`)
|
|
181
|
+
// for a committish that names a semver range instead of a literal ref. `git checkout` has no notion
|
|
182
|
+
// of this syntax, so it must be resolved to a concrete tag before checkout.
|
|
183
|
+
const SEMVER_COMMITTISH_PREFIX = /^semver:/i;
|
|
184
|
+
// Matches npm's own git-tag-to-version extraction (@npmcli/git's lines-to-revs.js): a trailing
|
|
185
|
+
// `1.2.3`-shaped suffix, optionally `v`-prefixed, with anything ahead of it ignored — so a tag like
|
|
186
|
+
// `release-v1.2.3` resolves the same way npm's own git-dependency installer treats it.
|
|
187
|
+
const TAG_VERSION_SUFFIX = /v?(\d+\.\d+\.\d+(?:[-+].+)?)$/;
|
|
188
|
+
// A conservative safe-charset check on the FULL tag name — not just the version-shaped suffix
|
|
189
|
+
// TAG_VERSION_SUFFIX matches. git's own ref-name rules (`check-ref-format`) permit shell
|
|
190
|
+
// metacharacters like `$`, backticks, `;`, `&`, `|`, `(`, `)` in a tag name; a prefix ahead of the
|
|
191
|
+
// matched suffix (e.g. the `release-` in `release-v1.2.3`) is otherwise unconstrained. The resolved
|
|
192
|
+
// name is later checked out via `nonInteractiveSpawn`, which runs through a shell with no argument
|
|
193
|
+
// escaping, so a tag such as `$(id)v1.2.3` in the cloned repo would otherwise execute on checkout.
|
|
194
|
+
// A tag failing this check is excluded from resolution entirely rather than sanitized or escaped —
|
|
195
|
+
// this only has to reject shell metacharacters, not accept every ref git itself would allow.
|
|
196
|
+
const SAFE_TAG_NAME = /^[\w][\w.-]*$/;
|
|
197
|
+
/**
|
|
198
|
+
* Resolves a `semver:<range>` committish (e.g. `semver:v1.2.3`, `semver:^1.2.3`) against the tags of
|
|
199
|
+
* the given clone to a concrete, unambiguous tag ref. Returns the committish unchanged if it isn't a
|
|
200
|
+
* semver-range committish.
|
|
201
|
+
*/
|
|
202
|
+
async function resolveCommittish(application, committish, cloneDir) {
|
|
203
|
+
if (!SEMVER_COMMITTISH_PREFIX.test(committish))
|
|
204
|
+
return committish;
|
|
205
|
+
// npm's own package-arg parser URL-decodes the value after `semver:` (a range containing `^`/`~`
|
|
206
|
+
// can arrive percent-encoded, e.g. `#semver:%5E1.0.0`); do the same rather than evaluating it raw.
|
|
207
|
+
const rawRange = committish.slice('semver:'.length);
|
|
208
|
+
let range;
|
|
209
|
+
try {
|
|
210
|
+
range = decodeURIComponent(rawRange);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
range = rawRange;
|
|
214
|
+
}
|
|
215
|
+
// `git tag --list` rather than `for-each-ref --format=...`: nonInteractiveSpawn runs through a
|
|
216
|
+
// shell, and a `%(...)` format string is unsafe to pass through one.
|
|
217
|
+
const { code, stdout, stderr } = await nonInteractiveSpawn(application.name, 'git', ['tag', '--list'], cloneDir);
|
|
218
|
+
if (code !== 0) {
|
|
219
|
+
throw new Error(`Failed to list tags to resolve '${committish}' for ${application.packageIdentifier}: ${stderr}`);
|
|
220
|
+
}
|
|
221
|
+
const tags = stdout
|
|
222
|
+
.split('\n')
|
|
223
|
+
.map((tag) => tag.trim())
|
|
224
|
+
.filter(Boolean);
|
|
225
|
+
// A tag can be a bare version (`v1.2.3`) or carry a prefix ahead of one (`release-v1.2.3`); only
|
|
226
|
+
// the trailing version-shaped suffix is evaluated against the range, but the ORIGINAL tag name is
|
|
227
|
+
// what gets checked out.
|
|
228
|
+
const versionToTag = new Map();
|
|
229
|
+
for (const tag of tags) {
|
|
230
|
+
if (!SAFE_TAG_NAME.test(tag))
|
|
231
|
+
continue;
|
|
232
|
+
const match = tag.match(TAG_VERSION_SUFFIX);
|
|
233
|
+
const version = match && semver_1.default.valid(match[1], { loose: true });
|
|
234
|
+
if (version)
|
|
235
|
+
versionToTag.set(semver_1.default.clean(match[1], { loose: true }), tag);
|
|
236
|
+
}
|
|
237
|
+
const resolvedVersion = semver_1.default.maxSatisfying([...versionToTag.keys()], range, { loose: true });
|
|
238
|
+
if (!resolvedVersion) {
|
|
239
|
+
throw new Error(`Failed to resolve '${committish}' for ${application.packageIdentifier}: no tag satisfies range '${range}'. ` +
|
|
240
|
+
(tags.length ? `Tags found: ${tags.join(', ')}` : 'No tags were found in the repository.'));
|
|
241
|
+
}
|
|
242
|
+
// refs/tags/<name> rather than the bare name: an unqualified `git checkout <name>` resolves to a
|
|
243
|
+
// same-named branch first if one exists, which would silently package the wrong commit.
|
|
244
|
+
return `refs/tags/${versionToTag.get(resolvedVersion)}`;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Clones a git reference ourselves — with the credential session's env, so the clone itself can
|
|
248
|
+
* still authenticate — and packs the checkout with its lifecycle scripts stripped, instead of
|
|
249
|
+
* letting `npm pack <git-url>` clone and pack it directly.
|
|
250
|
+
*
|
|
251
|
+
* `--ignore-scripts` is not a reliable suppression for a git source's own `prepare` script: pacote's
|
|
252
|
+
* DirFetcher runs it unconditionally on npm versions before 11.0.0 (the
|
|
253
|
+
* `if (this.opts.ignoreScripts) return` guard was only added upstream in npm 11) — which is exactly
|
|
254
|
+
* what Node 22's bundled npm (10.9.x) ships. A credentialed clone can't depend on which npm version
|
|
255
|
+
* happens to be on PATH, since a script running while the credential socket is reachable is exactly
|
|
256
|
+
* what this feature exists to prevent — so the script is removed from the checkout before packing,
|
|
257
|
+
* which works regardless of npm version.
|
|
258
|
+
*/
|
|
259
|
+
async function packGitReferenceWithoutScripts(application, gitRef, parentDirPath) {
|
|
260
|
+
const cloneDir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'harper-git-clone-'));
|
|
261
|
+
try {
|
|
262
|
+
const { code: cloneCode, stderr: cloneStderr } = await nonInteractiveSpawn(application.name, 'git', ['clone', '--quiet', gitRef.cloneUrl, cloneDir], parentDirPath, undefined, undefined, undefined, application.gitCredentialEnv);
|
|
263
|
+
if (cloneCode !== 0) {
|
|
264
|
+
if (isSSHAuthFailure(cloneStderr)) {
|
|
265
|
+
throw new Error(`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.`, { cause: new Error(cloneStderr) });
|
|
266
|
+
}
|
|
267
|
+
throw new Error(`Failed to clone package ${application.packageIdentifier}: ${cloneStderr}`);
|
|
268
|
+
}
|
|
269
|
+
if (gitRef.committish) {
|
|
270
|
+
const committish = await resolveCommittish(application, gitRef.committish, cloneDir);
|
|
271
|
+
const { code: checkoutCode, stderr: checkoutStderr } = await nonInteractiveSpawn(application.name, 'git', ['checkout', '--quiet', committish], cloneDir);
|
|
272
|
+
if (checkoutCode !== 0) {
|
|
273
|
+
throw new Error(`Failed to check out '${committish}' for ${application.packageIdentifier}: ${checkoutStderr}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Strip the checkout's own lifecycle scripts before packing — the mechanism above only
|
|
277
|
+
// suppresses npm's git-clone behavior; a plain `npm pack <local-dir>` still runs `prepare`
|
|
278
|
+
// unless it's gone from the manifest.
|
|
279
|
+
const manifestPath = (0, node_path_1.join)(cloneDir, 'package.json');
|
|
280
|
+
const manifest = JSON.parse(await (0, promises_1.readFile)(manifestPath, 'utf8'));
|
|
281
|
+
if (manifest.scripts) {
|
|
282
|
+
for (const scriptName of NEUTRALIZED_LIFECYCLE_SCRIPTS)
|
|
283
|
+
delete manifest.scripts[scriptName];
|
|
284
|
+
await (0, promises_1.writeFile)(manifestPath, JSON.stringify(manifest, null, 2));
|
|
285
|
+
}
|
|
286
|
+
const { stdout, code, stderr } = await nonInteractiveSpawn(application.name, 'npm', ['pack', '--json', '--ignore-scripts', cloneDir], parentDirPath, undefined, undefined, application.npmUserconfigPath);
|
|
287
|
+
if (code !== 0) {
|
|
288
|
+
throw new Error(`Failed to pack package ${application.packageIdentifier}: ${stderr}`);
|
|
289
|
+
}
|
|
290
|
+
let packResult;
|
|
291
|
+
try {
|
|
292
|
+
packResult = JSON.parse(stdout.slice(stdout.indexOf('[')));
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
throw new Error(`Failed to parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`);
|
|
296
|
+
}
|
|
297
|
+
if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') {
|
|
298
|
+
throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
|
|
299
|
+
}
|
|
300
|
+
return (0, node_path_1.join)(parentDirPath, packResult[0].filename);
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
await (0, promises_1.rm)(cloneDir, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
120
306
|
// Hidden directory under the components root holding component versions renamed aside
|
|
121
307
|
// during a deploy swap (see extractApplication). The leading dot keeps
|
|
122
308
|
// loadComponentDirectories from loading its contents as components.
|
|
123
309
|
exports.ASIDE_STAGING_DIR = '.deploy-aside';
|
|
310
|
+
// The credential helper git executes for a private git-reference deploy. It ships alongside this
|
|
311
|
+
// module (both in source and in dist), holds no secret, and is inert without a live session.
|
|
312
|
+
exports.GIT_CREDENTIAL_HELPER_PATH = (0, node_path_1.join)(__dirname, 'gitCredentialHelper.js');
|
|
124
313
|
/**
|
|
125
314
|
* Extract an application given payload (content of the application) or package (npm-compatible identifier to the application).
|
|
126
315
|
*
|
|
@@ -193,25 +382,56 @@ async function extractApplication(application) {
|
|
|
193
382
|
}
|
|
194
383
|
}
|
|
195
384
|
else {
|
|
196
|
-
// `npm pack --json` writes a JSON array describing the packed tarball(s).
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
385
|
+
// `npm pack --json` writes a JSON array describing the packed tarball(s). This is also the
|
|
386
|
+
// spawn that clones a git-reference package, so it is the only one given the git credential
|
|
387
|
+
// environment.
|
|
388
|
+
//
|
|
389
|
+
// Packing a git reference is not just a download: npm clones the repo and, if its manifest
|
|
390
|
+
// has a prepare/build/install script, runs `npm install` inside the clone and then that
|
|
391
|
+
// script — so the repo's own code AND its dependencies' install scripts execute on this node,
|
|
392
|
+
// inheriting this spawn's environment. With a credential session live, that is exactly the
|
|
393
|
+
// reach the credential must not have (a transitive dependency's postinstall could ask the
|
|
394
|
+
// socket for a token granted for the top-level repository), so scripts are off for a
|
|
395
|
+
// credentialed clone unless the deploy explicitly opted into them.
|
|
396
|
+
const scriptsDisallowed = application.gitCredentialEnv && !application.install?.allowInstallScripts;
|
|
397
|
+
// `--ignore-scripts` alone isn't a reliable way to enforce that: pacote's DirFetcher runs a
|
|
398
|
+
// git source's `prepare` unconditionally on npm versions before 11.0.0 (see
|
|
399
|
+
// packGitReferenceWithoutScripts), which is exactly what Node 22's bundled npm ships. For a
|
|
400
|
+
// recognized git-reference identifier, clone and pack it ourselves with scripts stripped
|
|
401
|
+
// instead, sidestepping that npm code path entirely.
|
|
402
|
+
const gitRef = scriptsDisallowed ? parseGitReference(application.packageIdentifier) : null;
|
|
403
|
+
if (gitRef) {
|
|
404
|
+
tarballPath = await packGitReferenceWithoutScripts(application, gitRef, parentDirPath);
|
|
207
405
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
406
|
+
else {
|
|
407
|
+
const packArgs = ['pack', '--json', application.packageIdentifier];
|
|
408
|
+
if (scriptsDisallowed) {
|
|
409
|
+
packArgs.push('--ignore-scripts');
|
|
410
|
+
}
|
|
411
|
+
else if (application.gitCredentialEnv) {
|
|
412
|
+
application.logger.warn(`Deploying ${application.name} from a git reference with install scripts enabled: the repository's ` +
|
|
413
|
+
`prepare/build scripts and its dependencies' install scripts run on this node during the clone and ` +
|
|
414
|
+
`can read the git credential. Unset install_allow_scripts to keep the credential out of their reach.`);
|
|
415
|
+
}
|
|
416
|
+
const { stdout, code, stderr } = await nonInteractiveSpawn(application.name, 'npm', packArgs, parentDirPath, undefined, undefined, application.npmUserconfigPath, application.gitCredentialEnv);
|
|
417
|
+
if (code !== 0) {
|
|
418
|
+
if (isSSHAuthFailure(stderr)) {
|
|
419
|
+
throw new Error(`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.`, { cause: new Error(stderr) });
|
|
420
|
+
}
|
|
421
|
+
throw new Error(`Failed to download package ${application.packageIdentifier}: ${stderr}`);
|
|
422
|
+
}
|
|
423
|
+
let packResult;
|
|
424
|
+
try {
|
|
425
|
+
packResult = JSON.parse(stdout.slice(stdout.indexOf('[')));
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
throw new Error(`Failed to parse npm pack output for ${application.packageIdentifier}: ${err.message}\nstdout: ${stdout}`);
|
|
429
|
+
}
|
|
430
|
+
if (!Array.isArray(packResult) || typeof packResult[0]?.filename !== 'string') {
|
|
431
|
+
throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
|
|
432
|
+
}
|
|
433
|
+
tarballPath = (0, node_path_1.join)(parentDirPath, packResult[0].filename);
|
|
213
434
|
}
|
|
214
|
-
tarballPath = (0, node_path_1.join)(parentDirPath, packResult[0].filename);
|
|
215
435
|
shouldDeleteTarball = true;
|
|
216
436
|
tarball = (0, node_fs_1.createReadStream)(tarballPath);
|
|
217
437
|
}
|
|
@@ -420,20 +640,36 @@ class Application {
|
|
|
420
640
|
dirPath;
|
|
421
641
|
logger;
|
|
422
642
|
packageManagerPrefix; // can be used to configure a package manager prefix, specifically "sfw".
|
|
423
|
-
// Transient registry
|
|
424
|
-
// per-deploy `.npmrc`; it is never persisted to config,
|
|
425
|
-
|
|
643
|
+
// Transient registry credentials provided by a deploy, already resolved to literal tokens. The
|
|
644
|
+
// token is held only in memory and a per-deploy `.npmrc`; it is never persisted to config,
|
|
645
|
+
// hdb_deployment, or replicated.
|
|
646
|
+
registryCredentials;
|
|
647
|
+
// Transient git-host credentials, likewise resolved to literal tokens and held only in memory:
|
|
648
|
+
// they are served to git over a per-deploy socket (gitCredentialServer.ts), never written anywhere.
|
|
649
|
+
gitCredentials;
|
|
426
650
|
// Path to the per-deploy `.npmrc`, set by writeTransientNpmrc() during prepareApplication and
|
|
427
|
-
// passed to the spawn calls; undefined when no registry
|
|
651
|
+
// passed to the spawn calls; undefined when no registry credentials were provided.
|
|
428
652
|
npmUserconfigPath;
|
|
429
653
|
#npmrcTempDir;
|
|
430
|
-
|
|
654
|
+
#gitCredentialSession;
|
|
655
|
+
constructor({ name, payload, packageIdentifier, install, onInstallLine, credentials }) {
|
|
431
656
|
this.name = name;
|
|
432
657
|
this.payload = payload;
|
|
433
658
|
this.packageIdentifier = packageIdentifier && derivePackageIdentifier(packageIdentifier);
|
|
434
659
|
this.install = install;
|
|
435
660
|
this.onInstallLine = onInstallLine;
|
|
436
|
-
|
|
661
|
+
// Split by kind: registry credentials go into the transient .npmrc, git credentials into the
|
|
662
|
+
// credential socket. An entry belongs to exactly one of them (the op schema is an xor).
|
|
663
|
+
// secretOperations owns the same predicate, but it is only ever imported from here lazily (it
|
|
664
|
+
// pulls in the datastore), so this stays a local check rather than a boot-time import.
|
|
665
|
+
if (credentials?.length) {
|
|
666
|
+
const registryCredentials = credentials.filter((entry) => entry.registry !== undefined);
|
|
667
|
+
const gitCredentials = credentials.filter((entry) => entry.registry === undefined);
|
|
668
|
+
if (registryCredentials.length)
|
|
669
|
+
this.registryCredentials = registryCredentials;
|
|
670
|
+
if (gitCredentials.length)
|
|
671
|
+
this.gitCredentials = gitCredentials;
|
|
672
|
+
}
|
|
437
673
|
const componentsRoot = (0, configUtils_ts_1.getConfigPath)(hdbTerms_ts_1.CONFIG_PARAMS.COMPONENTSROOT);
|
|
438
674
|
if (!componentsRoot)
|
|
439
675
|
throw new Error('componentsRoot is not configured');
|
|
@@ -442,7 +678,8 @@ class Application {
|
|
|
442
678
|
this.packageManagerPrefix = (0, configUtils_ts_1.getConfigValue)(hdbTerms_ts_1.CONFIG_PARAMS.APPLICATIONS_PACKAGEMANAGERPREFIX);
|
|
443
679
|
}
|
|
444
680
|
// Write the transient `.npmrc` into a fresh 0700 temp dir (file mode 0600) and record its path
|
|
445
|
-
// so the deploy's npm spawns authenticate against the private registry. No-op without registry
|
|
681
|
+
// so the deploy's npm spawns authenticate against the private registry. No-op without registry
|
|
682
|
+
// credentials.
|
|
446
683
|
//
|
|
447
684
|
// Because `nonInteractiveSpawn` points npm at this single file (replacing any inherited
|
|
448
685
|
// npm_config_userconfig), prepend the contents of an already-configured userconfig — e.g. a
|
|
@@ -450,7 +687,7 @@ class Application {
|
|
|
450
687
|
// survive. The transient auth is appended last so it wins on conflict (npm honors the last
|
|
451
688
|
// value for a given key).
|
|
452
689
|
async writeTransientNpmrc() {
|
|
453
|
-
if (!this.
|
|
690
|
+
if (!this.registryCredentials?.length)
|
|
454
691
|
return;
|
|
455
692
|
// Defensive: if called more than once, remove the prior temp dir first so it isn't leaked.
|
|
456
693
|
if (this.#npmrcTempDir)
|
|
@@ -470,10 +707,43 @@ class Application {
|
|
|
470
707
|
throw error;
|
|
471
708
|
}
|
|
472
709
|
}
|
|
473
|
-
content += buildNpmrcContent(this.
|
|
710
|
+
content += buildNpmrcContent(this.registryCredentials);
|
|
474
711
|
await (0, promises_1.writeFile)(npmrcPath, content, { mode: 0o600 });
|
|
475
712
|
this.npmUserconfigPath = npmrcPath;
|
|
476
713
|
}
|
|
714
|
+
// Environment that lets git reach this deploy's credential socket. Applied ONLY to the spawn that
|
|
715
|
+
// clones the git reference (`npm pack`) — see prepareApplication.
|
|
716
|
+
get gitCredentialEnv() {
|
|
717
|
+
return this.#gitCredentialSession?.env;
|
|
718
|
+
}
|
|
719
|
+
// Start serving this deploy's git-host credentials from memory. No-op without git credentials.
|
|
720
|
+
async startGitCredentialSession() {
|
|
721
|
+
if (!this.gitCredentials?.length)
|
|
722
|
+
return;
|
|
723
|
+
if (this.#gitCredentialSession)
|
|
724
|
+
await this.cleanupGitCredentialSession();
|
|
725
|
+
this.#gitCredentialSession = await (0, gitCredentialServer_ts_1.startGitCredentialSession)(this.gitCredentials, exports.GIT_CREDENTIAL_HELPER_PATH);
|
|
726
|
+
}
|
|
727
|
+
// Tear the socket down as soon as the clone is done, so nothing later in the deploy — including
|
|
728
|
+
// the component's own install scripts — can still ask for the credential.
|
|
729
|
+
async cleanupGitCredentialSession() {
|
|
730
|
+
const session = this.#gitCredentialSession;
|
|
731
|
+
if (!session)
|
|
732
|
+
return;
|
|
733
|
+
this.#gitCredentialSession = undefined;
|
|
734
|
+
try {
|
|
735
|
+
await session.close();
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
// Called from prepareApplication's finally; a throw here would mask the deploy's own error.
|
|
739
|
+
this.logger.warn(`Failed to close git credential session:`, error);
|
|
740
|
+
}
|
|
741
|
+
finally {
|
|
742
|
+
// Drop the in-memory tokens too, so they can't surface in a later heap dump or error
|
|
743
|
+
// serialization of this Application instance.
|
|
744
|
+
this.gitCredentials = undefined;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
477
747
|
// Remove the transient `.npmrc` (and its temp dir) once the deploy's npm work is done.
|
|
478
748
|
async cleanupTransientNpmrc() {
|
|
479
749
|
if (!this.#npmrcTempDir)
|
|
@@ -491,7 +761,7 @@ class Application {
|
|
|
491
761
|
this.npmUserconfigPath = undefined;
|
|
492
762
|
// Drop the in-memory token array too, so it can't surface in a later heap dump or error
|
|
493
763
|
// serialization of this Application instance.
|
|
494
|
-
this.
|
|
764
|
+
this.registryCredentials = undefined;
|
|
495
765
|
}
|
|
496
766
|
}
|
|
497
767
|
}
|
|
@@ -534,7 +804,17 @@ async function prepareApplication(application) {
|
|
|
534
804
|
// Materialize the per-deploy `.npmrc` before extraction so both `npm pack` (extract) and
|
|
535
805
|
// `npm install` authenticate against the private registry; always remove it afterward.
|
|
536
806
|
await application.writeTransientNpmrc();
|
|
537
|
-
|
|
807
|
+
try {
|
|
808
|
+
// The git credential socket only has to be up for extraction — that is where npm resolves and
|
|
809
|
+
// clones a git-reference package. Closing it before installApplication means the credential is
|
|
810
|
+
// already gone by the time the component's dependency tree (and any install script it is
|
|
811
|
+
// allowed to run) executes.
|
|
812
|
+
await application.startGitCredentialSession();
|
|
813
|
+
await extractApplication(application);
|
|
814
|
+
}
|
|
815
|
+
finally {
|
|
816
|
+
await application.cleanupGitCredentialSession();
|
|
817
|
+
}
|
|
538
818
|
await installApplication(application);
|
|
539
819
|
}
|
|
540
820
|
finally {
|
|
@@ -589,26 +869,26 @@ async function installApplications() {
|
|
|
589
869
|
// Then do proper error-based validation with TypeScript `asserts` to provide type safety
|
|
590
870
|
// This will throw if the config is invalid
|
|
591
871
|
assertApplicationConfig(name, applicationConfig);
|
|
592
|
-
// Resolve any
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
let
|
|
598
|
-
if (applicationConfig.
|
|
872
|
+
// Resolve any credential references from the store so a cold install (fresh node, wiped
|
|
873
|
+
// components dir, new peer that never installed) can authenticate without the token being
|
|
874
|
+
// re-supplied. Best-effort: if custody isn't available yet or a referenced secret is
|
|
875
|
+
// missing, log and install without it (a truly private package then fails in npm with its
|
|
876
|
+
// own error) rather than blocking boot.
|
|
877
|
+
let credentials;
|
|
878
|
+
if (applicationConfig.credentials?.length) {
|
|
599
879
|
try {
|
|
600
|
-
const {
|
|
601
|
-
|
|
880
|
+
const { resolveCredentials } = await import("./secretOperations.js");
|
|
881
|
+
credentials = await resolveCredentials(applicationConfig.credentials, name);
|
|
602
882
|
}
|
|
603
883
|
catch (error) {
|
|
604
|
-
harper_logger_ts_1.default.warn?.(`Could not resolve
|
|
884
|
+
harper_logger_ts_1.default.warn?.(`Could not resolve credentials for application ${name} at install time: ${error.message}`);
|
|
605
885
|
}
|
|
606
886
|
}
|
|
607
887
|
const application = new Application({
|
|
608
888
|
name,
|
|
609
889
|
packageIdentifier: applicationConfig.package,
|
|
610
890
|
install: applicationConfig.install,
|
|
611
|
-
|
|
891
|
+
credentials,
|
|
612
892
|
});
|
|
613
893
|
// Lock check: only install if not already installed with matching configuration
|
|
614
894
|
if ((0, node_fs_1.existsSync)(application.dirPath) &&
|
|
@@ -630,16 +910,122 @@ async function installApplications() {
|
|
|
630
910
|
// Finally, write the lock file
|
|
631
911
|
await (0, promises_1.writeFile)(harperApplicationLockPath, JSON.stringify(harperApplicationLock, null, 2), 'utf8');
|
|
632
912
|
}
|
|
633
|
-
|
|
913
|
+
/**
|
|
914
|
+
* Rewrite every occurrence of `sshDir` in an ssh `config` file's contents to `tempDir`, matching
|
|
915
|
+
* either slash direction per path segment (and case-insensitively on `win32`) rather than relying
|
|
916
|
+
* on the two strings being byte-identical.
|
|
917
|
+
*
|
|
918
|
+
* A plain string substitution (`sshConfig.split(sshDir).join(tempDir)`) doesn't hold
|
|
919
|
+
* cross-platform: ssh config files are frequently forward-slash even on Windows, while `sshDir`
|
|
920
|
+
* (built via `path.join`) is backslash there, so the split would silently never match; Windows
|
|
921
|
+
* paths are also case-insensitive. `sshDir` is split into segments on either separator *before*
|
|
922
|
+
* escaping, so escaping a regex-special character within a segment (e.g. a literal paren in a
|
|
923
|
+
* directory name) can't interact with the separator substitution. A trailing lookahead keeps
|
|
924
|
+
* `sshDir` from partial-matching a sibling directory whose name happens to start with it (e.g.
|
|
925
|
+
* `.../ssh` vs `.../sshhh`).
|
|
926
|
+
*
|
|
927
|
+
* Exported only so unit tests can pin the Windows-path behavior (mixed slash direction, case
|
|
928
|
+
* insensitivity) directly via the `platform` override, without needing to run on Windows.
|
|
929
|
+
*/
|
|
930
|
+
function rewriteSshConfigPaths(sshConfig, sshDir, tempDir, platform = process.platform) {
|
|
931
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
932
|
+
const sepClass = '[\\\\/]';
|
|
933
|
+
const sshDirPattern = sshDir.split(/[\\/]/).map(escapeRegExp).join(sepClass) + `(?=${sepClass}|$)`;
|
|
934
|
+
const sshDirRegex = new RegExp(sshDirPattern, platform === 'win32' ? 'gi' : 'g');
|
|
935
|
+
const normalizedTempDir = platform === 'win32' ? tempDir.replace(/\\/g, '/') : tempDir;
|
|
936
|
+
// Function replacer: a string replacer would treat `$` sequences in normalizedTempDir
|
|
937
|
+
// (e.g. `$&`, `$1`) as replacement patterns instead of literal characters.
|
|
938
|
+
return sshConfig.replace(sshDirRegex, () => normalizedTempDir);
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Materialize the SSH deploy keys for the lifetime of a single git-over-SSH spawn.
|
|
942
|
+
*
|
|
943
|
+
* Keys are sealed at rest as `enc:v1:` envelopes (Harper Pro's `add_ssh_key`), but ssh needs a key
|
|
944
|
+
* *file*, so each key is decrypted into a fresh 0700 temp dir as a 0600 file and the ssh config is
|
|
945
|
+
* copied with its `IdentityFile` paths repointed at the transient copies. The caller removes the
|
|
946
|
+
* dir as soon as the spawn settles, so the plaintext never outlives the git invocation. Legacy
|
|
947
|
+
* plaintext keys (written before sealing, or on a node with no custody) are copied through
|
|
948
|
+
* unchanged, so this is a no-op for them beyond the temp dir.
|
|
949
|
+
*
|
|
950
|
+
* A key that cannot be decrypted — no custody registered on this node, an envelope sealed under a
|
|
951
|
+
* different cluster key, or a tampered envelope — is logged and skipped rather than failing the
|
|
952
|
+
* spawn: a deploy that doesn't need that key is unaffected, and one that does fails with the usual
|
|
953
|
+
* SSH auth error (see `isSSHAuthFailure`). No log or error message here carries key material or
|
|
954
|
+
* the envelope.
|
|
955
|
+
*
|
|
956
|
+
* @returns The `GIT_SSH_COMMAND` to use plus its cleanup, or undefined when no keys are configured.
|
|
957
|
+
*/
|
|
958
|
+
async function materializeGitSSH() {
|
|
634
959
|
const rootDir = (0, configUtils_ts_1.getConfigValue)(hdbTerms_ts_1.CONFIG_PARAMS.ROOTPATH);
|
|
960
|
+
if (!rootDir)
|
|
961
|
+
return; // config not initialized (e.g. an install-time spawn) — no ssh dir to read
|
|
635
962
|
const sshDir = (0, node_path_1.join)(rootDir, 'ssh');
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
963
|
+
// `withFileTypes` so a stray subdirectory in the ssh dir (e.g. one named `foo.key`) is filtered
|
|
964
|
+
// out here rather than reaching `readFile` below and throwing EISDIR, which would abort the
|
|
965
|
+
// whole spawn instead of just skipping that one entry.
|
|
966
|
+
const sshDirEntries = await (0, promises_1.readdir)(sshDir, { withFileTypes: true }).catch(() => undefined);
|
|
967
|
+
if (!sshDirEntries)
|
|
968
|
+
return; // no ssh dir on this node
|
|
969
|
+
const keyFiles = sshDirEntries
|
|
970
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.key'))
|
|
971
|
+
.map((entry) => entry.name);
|
|
972
|
+
if (keyFiles.length === 0)
|
|
973
|
+
return;
|
|
974
|
+
const tempDir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'harper-ssh-'));
|
|
975
|
+
const cleanup = async () => {
|
|
976
|
+
try {
|
|
977
|
+
await (0, promises_1.rm)(tempDir, { recursive: true, force: true });
|
|
978
|
+
}
|
|
979
|
+
catch (error) {
|
|
980
|
+
// never mask the caller's error (this runs from a finally) — a leaked temp dir is the
|
|
981
|
+
// lesser failure, and it holds only 0600 files in a 0700 dir
|
|
982
|
+
harper_logger_ts_1.default.warn?.(`Failed to remove transient ssh dir ${tempDir}:`, error);
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
try {
|
|
986
|
+
const decryptor = (0, secretDecryptor_ts_1.getSecretDecryptor)();
|
|
987
|
+
for (const keyFile of keyFiles) {
|
|
988
|
+
let storedKey;
|
|
989
|
+
try {
|
|
990
|
+
storedKey = await (0, promises_1.readFile)((0, node_path_1.join)(sshDir, keyFile), 'utf8');
|
|
640
991
|
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
// Same fail-open policy as a decrypt failure below: a key that can't even be read
|
|
994
|
+
// (permission drift, deleted mid-scan by a concurrent add_ssh_key/rotation, a
|
|
995
|
+
// transient EIO) should drop that one key, not abort a spawn that may not need it.
|
|
996
|
+
harper_logger_ts_1.default.error?.(`Failed to read SSH key ${keyFile}: ${error.message}; skipping it`);
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
let keyMaterial = storedKey;
|
|
1000
|
+
if (storedKey.startsWith(envFile_ts_1.ENV_ENCRYPTED_PREFIX)) {
|
|
1001
|
+
if (!decryptor) {
|
|
1002
|
+
harper_logger_ts_1.default.error?.(`SSH key ${keyFile} is encrypted but no secret custody is registered on this node; skipping it`);
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
try {
|
|
1006
|
+
keyMaterial = decryptor(storedKey);
|
|
1007
|
+
}
|
|
1008
|
+
catch (error) {
|
|
1009
|
+
harper_logger_ts_1.default.error?.(`Failed to decrypt SSH key ${keyFile}: ${error.message}; skipping it`);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
await (0, promises_1.writeFile)((0, node_path_1.join)(tempDir, keyFile), keyMaterial, { mode: 0o600 });
|
|
641
1014
|
}
|
|
1015
|
+
// The config's `IdentityFile` lines are absolute paths into the durable ssh dir; repoint
|
|
1016
|
+
// them at the transient copies. known_hosts holds no secrets and stays where it is.
|
|
1017
|
+
const sshConfig = await (0, promises_1.readFile)((0, node_path_1.join)(sshDir, 'config'), 'utf8').catch(() => '');
|
|
1018
|
+
const rewrittenConfig = rewriteSshConfigPaths(sshConfig, sshDir, tempDir);
|
|
1019
|
+
await (0, promises_1.writeFile)((0, node_path_1.join)(tempDir, 'config'), rewrittenConfig, { mode: 0o600 });
|
|
1020
|
+
}
|
|
1021
|
+
catch (error) {
|
|
1022
|
+
await cleanup();
|
|
1023
|
+
throw error;
|
|
642
1024
|
}
|
|
1025
|
+
return {
|
|
1026
|
+
command: `ssh -F ${(0, node_path_1.join)(tempDir, 'config')} -o UserKnownHostsFile=${(0, node_path_1.join)(sshDir, 'known_hosts')}`,
|
|
1027
|
+
cleanup,
|
|
1028
|
+
};
|
|
643
1029
|
}
|
|
644
1030
|
// Normalize a registry to a full URL with a scheme and trailing slash, e.g.
|
|
645
1031
|
// `npm.pkg.github.com` or `//npm.pkg.github.com` → `https://npm.pkg.github.com/`.
|
|
@@ -652,16 +1038,16 @@ function normalizeRegistryUrl(registry) {
|
|
|
652
1038
|
url += '/';
|
|
653
1039
|
return url;
|
|
654
1040
|
}
|
|
655
|
-
// Build the contents of a transient `.npmrc` from registry
|
|
1041
|
+
// Build the contents of a transient `.npmrc` from resolved registry credentials: an auth-token line keyed
|
|
656
1042
|
// by npm's registry auth key (scheme stripped, leading `//`, trailing `/`) plus a registry-routing
|
|
657
1043
|
// line. A scope routes only that `@scope` to the registry (`@scope:registry=…`); without a scope
|
|
658
1044
|
// the entry sets npm's default `registry=…` so an unscoped package spec (e.g. `npm:my-private-app`)
|
|
659
1045
|
// or its transitive deps actually resolve against this registry rather than the public default.
|
|
660
1046
|
// A scope-less entry therefore requires its registry to serve/proxy whatever npm needs to install;
|
|
661
1047
|
// with multiple scope-less entries npm's last-value-wins applies to the default `registry`.
|
|
662
|
-
function buildNpmrcContent(
|
|
1048
|
+
function buildNpmrcContent(registryCredentials) {
|
|
663
1049
|
const lines = [];
|
|
664
|
-
for (const { registry, token, scope } of
|
|
1050
|
+
for (const { registry, token, scope } of registryCredentials) {
|
|
665
1051
|
// Enforce the no-newline invariant at the injection point so it holds for every source. The
|
|
666
1052
|
// ops validator already rejects CR/LF in a literal `token`, but a token resolved from an
|
|
667
1053
|
// hdb_secret row bypasses that guard; without this a `\n` in a secret value would inject
|
|
@@ -727,16 +1113,41 @@ function createLineSplitter(onLine) {
|
|
|
727
1113
|
},
|
|
728
1114
|
};
|
|
729
1115
|
}
|
|
730
|
-
|
|
1116
|
+
/**
|
|
1117
|
+
* Run a command with the deploy's SSH key material materialized only for the duration of the
|
|
1118
|
+
* spawn. The keys are decrypted to a transient 0700 dir (see `materializeGitSSH`) and removed as
|
|
1119
|
+
* soon as the process settles — on success, failure, and timeout alike.
|
|
1120
|
+
*/
|
|
1121
|
+
async function nonInteractiveSpawn(applicationName, command, args, cwd, timeoutMs = 60 * 60 * 1000, onLine, npmUserconfigPath, gitCredentialEnv) {
|
|
1122
|
+
const gitSSH = await materializeGitSSH();
|
|
1123
|
+
try {
|
|
1124
|
+
return await spawnWithEnv(applicationName, command, args, cwd, timeoutMs, onLine, npmUserconfigPath, gitSSH?.command, gitCredentialEnv);
|
|
1125
|
+
}
|
|
1126
|
+
finally {
|
|
1127
|
+
await gitSSH?.cleanup();
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
function spawnWithEnv(applicationName, command, args, cwd, timeoutMs, onLine, npmUserconfigPath, gitSSHCommand, gitCredentialEnv) {
|
|
731
1131
|
return new Promise((resolve, reject) => {
|
|
732
1132
|
harper_logger_ts_1.default
|
|
733
1133
|
.loggerWithTag(`${applicationName}:spawn:${command}`)
|
|
734
1134
|
.debug?.(`Executing \`${command} ${args.join(' ')}\` in ${cwd}`);
|
|
735
1135
|
const env = { ...process.env };
|
|
736
|
-
const gitSSHCommand = getGitSSHCommand();
|
|
737
1136
|
if (gitSSHCommand) {
|
|
738
1137
|
env.GIT_SSH_COMMAND = gitSSHCommand;
|
|
739
1138
|
}
|
|
1139
|
+
// The git credential channel is granted per spawn, and only to the one that clones the git
|
|
1140
|
+
// reference. Every other spawn — notably `npm install`, where a dependency's install script can
|
|
1141
|
+
// run — has it removed, so a transitive dependency cannot ask for a credential that was supplied
|
|
1142
|
+
// for the top-level repository. Only the socket variable is stripped rather than any inherited
|
|
1143
|
+
// GIT_ASKPASS/GIT_CONFIG_*: those may be the operator's own git auth, and without a socket to
|
|
1144
|
+
// reach, our helper answers nothing and is inert.
|
|
1145
|
+
if (gitCredentialEnv) {
|
|
1146
|
+
Object.assign(env, gitCredentialEnv);
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
delete env[gitCredentialServer_ts_1.GIT_CREDENTIAL_SOCKET_ENV];
|
|
1150
|
+
}
|
|
740
1151
|
// A deploy carrying transient registry auth points npm at a per-deploy `.npmrc` so
|
|
741
1152
|
// `npm pack`/`install` can authenticate against a private registry without the token
|
|
742
1153
|
// ever touching disk durably, the package reference, config, or hdb_deployment.
|