@saws/core 2.0.0-beta.2 → 2.0.0-beta.21
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/dist/.tsbuildinfo +1 -1
- package/dist/Host.d.ts +62 -0
- package/dist/Host.js +202 -0
- package/dist/ServiceDefinition.d.ts +66 -0
- package/dist/ServiceDefinition.js +182 -0
- package/dist/find-service-definition.d.ts +1 -8
- package/dist/find-service-definition.js +0 -6
- package/dist/get-saws-config.d.ts +14 -3
- package/dist/get-saws-config.js +16 -54
- package/dist/global-hosts.d.ts +8 -0
- package/dist/global-hosts.js +119 -0
- package/dist/host-readiness.d.ts +12 -0
- package/dist/host-readiness.js +230 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +5 -4
- package/dist/secrets-manager.d.ts +66 -0
- package/dist/secrets-manager.js +325 -0
- package/dist/utils/constants.d.ts +4 -0
- package/dist/utils/constants.js +5 -0
- package/dist/utils/create-directories.d.ts +2 -0
- package/dist/utils/create-directories.js +20 -0
- package/dist/utils/create-file-if-not-exists.d.ts +1 -0
- package/dist/utils/create-file-if-not-exists.js +12 -0
- package/dist/utils/dependency-management.d.ts +16 -0
- package/dist/utils/dependency-management.js +132 -0
- package/dist/utils/file-exists.d.ts +1 -0
- package/dist/utils/file-exists.js +12 -0
- package/dist/utils/generate-token.d.ts +1 -0
- package/dist/utils/generate-token.js +13 -0
- package/dist/utils/get-project-name.d.ts +1 -0
- package/dist/utils/get-project-name.js +5 -0
- package/dist/utils/get-service-path.js +1 -0
- package/dist/utils/list-files.d.ts +1 -0
- package/dist/utils/list-files.js +17 -0
- package/dist/utils/on-exit.d.ts +1 -0
- package/dist/utils/on-exit.js +14 -0
- package/dist/utils/parameterized-env-var-name.d.ts +1 -0
- package/dist/utils/parameterized-env-var-name.js +1 -0
- package/dist/utils/recursively-read-dir.d.ts +1 -0
- package/dist/utils/recursively-read-dir.js +15 -0
- package/dist/utils/retry-until.d.ts +1 -0
- package/dist/utils/retry-until.js +9 -0
- package/dist/utils/run-local.d.ts +9 -0
- package/dist/utils/run-local.js +87 -0
- package/dist/utils/shell-quote.d.ts +1 -0
- package/dist/utils/shell-quote.js +3 -0
- package/dist/utils/stage-outputs.d.ts +3 -0
- package/dist/utils/stage-outputs.js +24 -0
- package/dist/utils/uppercase.d.ts +1 -0
- package/dist/utils/uppercase.js +4 -0
- package/package.json +34 -21
- package/dist/context.d.ts +0 -41
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -38
- package/dist/context.test.d.ts +0 -2
- package/dist/context.test.d.ts.map +0 -1
- package/dist/context.test.js +0 -13
- package/dist/find-service-definition.d.ts.map +0 -1
- package/dist/find-service-definition.test.d.ts +0 -2
- package/dist/find-service-definition.test.d.ts.map +0 -1
- package/dist/find-service-definition.test.js +0 -44
- package/dist/get-saws-config.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/service-commands.d.ts +0 -10
- package/dist/service-commands.d.ts.map +0 -1
- package/dist/service-commands.js +0 -25
- package/dist/service-commands.test.d.ts +0 -2
- package/dist/service-commands.test.d.ts.map +0 -1
- package/dist/service-commands.test.js +0 -26
- package/dist/service-definition.d.ts +0 -36
- package/dist/service-definition.d.ts.map +0 -1
- package/dist/service-definition.js +0 -104
- package/dist/service-types.d.ts +0 -9
- package/dist/service-types.d.ts.map +0 -1
- /package/dist/{service-types.js → utils/get-service-path.d.ts} +0 -0
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
export * from "./
|
|
1
|
+
export * from "./ServiceDefinition.js";
|
|
2
2
|
export * from "./find-service-definition.js";
|
|
3
3
|
export * from "./get-saws-config.js";
|
|
4
|
-
export * from "./
|
|
5
|
-
export * from "./
|
|
6
|
-
export * from "./
|
|
4
|
+
export * from "./Host.js";
|
|
5
|
+
export * from "./global-hosts.js";
|
|
6
|
+
export * from "./host-readiness.js";
|
|
7
|
+
export * from "./secrets-manager.js";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface SecretsManagerConfig {
|
|
2
|
+
/** Overrides the stage derived from the command's runtime context or STAGE. */
|
|
3
|
+
stage?: string;
|
|
4
|
+
rootDir?: string;
|
|
5
|
+
/** Overrides the directory containing secrets state. Defaults to `<rootDir>/.saws`. */
|
|
6
|
+
sawsDirectory?: string;
|
|
7
|
+
/** Overrides SAWS_SECRETS_PASSCODE and the project-root .env file. */
|
|
8
|
+
passcode?: string;
|
|
9
|
+
}
|
|
10
|
+
export type SecretScope = "stage" | "global";
|
|
11
|
+
export interface SecretResolutionContext {
|
|
12
|
+
stage: string;
|
|
13
|
+
rootDir?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare class ParameterNotFoundError extends Error {
|
|
16
|
+
constructor(name: string);
|
|
17
|
+
}
|
|
18
|
+
export declare class SecretsDecryptionError extends Error {
|
|
19
|
+
constructor();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A lazy reference to either a stage-aware or global secret.
|
|
23
|
+
*
|
|
24
|
+
* References can safely be created while saws.ts is loaded, before the CLI
|
|
25
|
+
* knows which runtime stage will consume a stage-aware secret.
|
|
26
|
+
*/
|
|
27
|
+
export declare class SecretReference {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly scope: SecretScope;
|
|
30
|
+
private readonly manager;
|
|
31
|
+
constructor(manager: SecretsManager, name: string, scope: SecretScope);
|
|
32
|
+
resolve(context?: SecretResolutionContext): Promise<string>;
|
|
33
|
+
/** @internal */
|
|
34
|
+
isManagedBy(manager: SecretsManager): boolean;
|
|
35
|
+
}
|
|
36
|
+
export declare class GlobalSecrets {
|
|
37
|
+
private readonly manager;
|
|
38
|
+
constructor(manager: SecretsManager);
|
|
39
|
+
reference(name: string): SecretReference;
|
|
40
|
+
get(name: string): Promise<string>;
|
|
41
|
+
set(name: string, value: string): Promise<void>;
|
|
42
|
+
get secretsFilePath(): string;
|
|
43
|
+
}
|
|
44
|
+
export declare class SecretsManager {
|
|
45
|
+
readonly global: GlobalSecrets;
|
|
46
|
+
readonly rootDir: string;
|
|
47
|
+
readonly sawsDirectory: string;
|
|
48
|
+
private readonly configuredStage?;
|
|
49
|
+
private readonly configuredPasscode?;
|
|
50
|
+
constructor(config?: SecretsManagerConfig);
|
|
51
|
+
get stage(): string;
|
|
52
|
+
reference(name: string): SecretReference;
|
|
53
|
+
get(name: string): Promise<string>;
|
|
54
|
+
set(name: string, value: string): Promise<void>;
|
|
55
|
+
get secretsFilePath(): string;
|
|
56
|
+
/** @internal */
|
|
57
|
+
getFromScope(name: string, scope: SecretScope, resolutionStage?: string): Promise<string>;
|
|
58
|
+
/** @internal */
|
|
59
|
+
setInScope(name: string, value: string, scope: SecretScope, resolutionStage?: string): Promise<void>;
|
|
60
|
+
/** @internal */
|
|
61
|
+
getSecretsFilePath(scope: SecretScope, resolutionStage?: string): string;
|
|
62
|
+
private resolveStage;
|
|
63
|
+
private readSecrets;
|
|
64
|
+
private writeSecrets;
|
|
65
|
+
private getPasscode;
|
|
66
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { appendFile, chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const PASSCODE_ENV_NAME = "SAWS_SECRETS_PASSCODE";
|
|
5
|
+
const GLOBAL_SCOPE = "global";
|
|
6
|
+
const ENCRYPTION_VERSION = 1;
|
|
7
|
+
const KEY_LENGTH = 32;
|
|
8
|
+
const SCRYPT_COST = 16_384;
|
|
9
|
+
const SCRYPT_BLOCK_SIZE = 8;
|
|
10
|
+
const SCRYPT_PARALLELIZATION = 1;
|
|
11
|
+
export class ParameterNotFoundError extends Error {
|
|
12
|
+
constructor(name) {
|
|
13
|
+
super(`Secret not found: ${name}`);
|
|
14
|
+
this.name = "ParameterNotFound";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class SecretsDecryptionError extends Error {
|
|
18
|
+
constructor() {
|
|
19
|
+
super(`Unable to decrypt secrets. Check ${PASSCODE_ENV_NAME} in the project-root .env file.`);
|
|
20
|
+
this.name = "SecretsDecryptionError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A lazy reference to either a stage-aware or global secret.
|
|
25
|
+
*
|
|
26
|
+
* References can safely be created while saws.ts is loaded, before the CLI
|
|
27
|
+
* knows which runtime stage will consume a stage-aware secret.
|
|
28
|
+
*/
|
|
29
|
+
export class SecretReference {
|
|
30
|
+
name;
|
|
31
|
+
scope;
|
|
32
|
+
manager;
|
|
33
|
+
constructor(manager, name, scope) {
|
|
34
|
+
if (name.trim().length === 0) {
|
|
35
|
+
throw new Error("Secret reference name cannot be empty");
|
|
36
|
+
}
|
|
37
|
+
this.manager = manager;
|
|
38
|
+
this.name = name;
|
|
39
|
+
this.scope = scope;
|
|
40
|
+
}
|
|
41
|
+
resolve(context) {
|
|
42
|
+
if (this.scope === "stage" && context == null) {
|
|
43
|
+
return this.manager.getFromScope(this.name, this.scope);
|
|
44
|
+
}
|
|
45
|
+
return this.manager.getFromScope(this.name, this.scope, context?.stage);
|
|
46
|
+
}
|
|
47
|
+
/** @internal */
|
|
48
|
+
isManagedBy(manager) {
|
|
49
|
+
return this.manager === manager;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class GlobalSecrets {
|
|
53
|
+
manager;
|
|
54
|
+
constructor(manager) {
|
|
55
|
+
this.manager = manager;
|
|
56
|
+
}
|
|
57
|
+
reference(name) {
|
|
58
|
+
return new SecretReference(this.manager, name, GLOBAL_SCOPE);
|
|
59
|
+
}
|
|
60
|
+
async get(name) {
|
|
61
|
+
return this.manager.getFromScope(name, GLOBAL_SCOPE);
|
|
62
|
+
}
|
|
63
|
+
async set(name, value) {
|
|
64
|
+
await this.manager.setInScope(name, value, GLOBAL_SCOPE);
|
|
65
|
+
}
|
|
66
|
+
get secretsFilePath() {
|
|
67
|
+
return this.manager.getSecretsFilePath(GLOBAL_SCOPE);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export class SecretsManager {
|
|
71
|
+
global;
|
|
72
|
+
rootDir;
|
|
73
|
+
sawsDirectory;
|
|
74
|
+
configuredStage;
|
|
75
|
+
configuredPasscode;
|
|
76
|
+
constructor(config = {}) {
|
|
77
|
+
this.configuredStage = config.stage == null ? undefined : requireValidStage(config.stage);
|
|
78
|
+
this.rootDir = config.rootDir ?? process.cwd();
|
|
79
|
+
this.sawsDirectory = config.sawsDirectory ?? path.resolve(this.rootDir, ".saws");
|
|
80
|
+
this.configuredPasscode = config.passcode;
|
|
81
|
+
this.global = new GlobalSecrets(this);
|
|
82
|
+
}
|
|
83
|
+
get stage() {
|
|
84
|
+
return this.resolveStage();
|
|
85
|
+
}
|
|
86
|
+
reference(name) {
|
|
87
|
+
return new SecretReference(this, name, "stage");
|
|
88
|
+
}
|
|
89
|
+
async get(name) {
|
|
90
|
+
return this.getFromScope(name, "stage");
|
|
91
|
+
}
|
|
92
|
+
async set(name, value) {
|
|
93
|
+
await this.setInScope(name, value, "stage");
|
|
94
|
+
}
|
|
95
|
+
get secretsFilePath() {
|
|
96
|
+
return this.getSecretsFilePath("stage");
|
|
97
|
+
}
|
|
98
|
+
/** @internal */
|
|
99
|
+
async getFromScope(name, scope, resolutionStage) {
|
|
100
|
+
const secrets = await this.readSecrets(scope, resolutionStage);
|
|
101
|
+
const value = secrets[name];
|
|
102
|
+
if (value == null)
|
|
103
|
+
throw new ParameterNotFoundError(name);
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
/** @internal */
|
|
107
|
+
async setInScope(name, value, scope, resolutionStage) {
|
|
108
|
+
const secrets = await this.readSecrets(scope, resolutionStage);
|
|
109
|
+
secrets[name] = value;
|
|
110
|
+
await this.writeSecrets(secrets, scope, resolutionStage);
|
|
111
|
+
}
|
|
112
|
+
/** @internal */
|
|
113
|
+
getSecretsFilePath(scope, resolutionStage) {
|
|
114
|
+
const fileName = scope === GLOBAL_SCOPE ? "global.env" : `${this.resolveStage(resolutionStage)}.env`;
|
|
115
|
+
return path.resolve(this.sawsDirectory, "secrets", fileName);
|
|
116
|
+
}
|
|
117
|
+
resolveStage(resolutionStage) {
|
|
118
|
+
const stage = this.configuredStage ?? resolutionStage ?? process.env.STAGE;
|
|
119
|
+
if (stage == null || stage.length === 0) {
|
|
120
|
+
throw new Error("Unable to determine the secrets stage. Run the command with --stage or set STAGE.");
|
|
121
|
+
}
|
|
122
|
+
return requireValidStage(stage);
|
|
123
|
+
}
|
|
124
|
+
async readSecrets(scope, resolutionStage) {
|
|
125
|
+
const secretsFilePath = this.getSecretsFilePath(scope, resolutionStage);
|
|
126
|
+
let contents;
|
|
127
|
+
try {
|
|
128
|
+
contents = await readFile(secretsFilePath, "utf8");
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (error.code !== "ENOENT")
|
|
132
|
+
throw error;
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
const encrypted = parseEncryptedFile(contents);
|
|
136
|
+
if (encrypted != null) {
|
|
137
|
+
const passcode = await this.getPasscode(false);
|
|
138
|
+
if (passcode == null) {
|
|
139
|
+
throw new Error(`${PASSCODE_ENV_NAME} is required to decrypt ${secretsFilePath}`);
|
|
140
|
+
}
|
|
141
|
+
return decryptSecrets(encrypted, passcode);
|
|
142
|
+
}
|
|
143
|
+
// Migrate files written by older SAWS versions as soon as they are used.
|
|
144
|
+
const secrets = parseEnvFile(contents);
|
|
145
|
+
await this.writeSecrets(secrets, scope, resolutionStage);
|
|
146
|
+
return secrets;
|
|
147
|
+
}
|
|
148
|
+
async writeSecrets(secrets, scope, resolutionStage) {
|
|
149
|
+
const passcode = await this.getPasscode(true);
|
|
150
|
+
if (passcode == null) {
|
|
151
|
+
throw new Error(`Unable to create ${PASSCODE_ENV_NAME}`);
|
|
152
|
+
}
|
|
153
|
+
const secretsFilePath = this.getSecretsFilePath(scope, resolutionStage);
|
|
154
|
+
await mkdir(path.dirname(secretsFilePath), { recursive: true });
|
|
155
|
+
const temporaryPath = `${secretsFilePath}.${randomBytes(8).toString("hex")}.tmp`;
|
|
156
|
+
try {
|
|
157
|
+
await writeFile(temporaryPath, JSON.stringify(await encryptSecrets(secrets, passcode), null, 2).concat("\n"), { mode: 0o600 });
|
|
158
|
+
await rename(temporaryPath, secretsFilePath);
|
|
159
|
+
await chmod(secretsFilePath, 0o600);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
await rm(temporaryPath, { force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async getPasscode(createIfMissing) {
|
|
166
|
+
if (this.configuredPasscode != null) {
|
|
167
|
+
return requireNonemptyPasscode(this.configuredPasscode);
|
|
168
|
+
}
|
|
169
|
+
if (process.env[PASSCODE_ENV_NAME] != null) {
|
|
170
|
+
return requireNonemptyPasscode(process.env[PASSCODE_ENV_NAME]);
|
|
171
|
+
}
|
|
172
|
+
const envFilePath = path.resolve(this.rootDir, ".env");
|
|
173
|
+
try {
|
|
174
|
+
const environment = parseEnvFile(await readFile(envFilePath, "utf8"));
|
|
175
|
+
if (environment[PASSCODE_ENV_NAME] != null) {
|
|
176
|
+
return requireNonemptyPasscode(environment[PASSCODE_ENV_NAME]);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error.code !== "ENOENT")
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
if (!createIfMissing)
|
|
184
|
+
return undefined;
|
|
185
|
+
const passcode = randomBytes(32).toString("base64url");
|
|
186
|
+
let needsLeadingNewline = false;
|
|
187
|
+
try {
|
|
188
|
+
const contents = await readFile(envFilePath, "utf8");
|
|
189
|
+
needsLeadingNewline = contents.length > 0 && !contents.endsWith("\n");
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error.code !== "ENOENT")
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
await appendFile(envFilePath, `${needsLeadingNewline ? "\n" : ""}${PASSCODE_ENV_NAME}=${passcode}\n`, { mode: 0o600 });
|
|
196
|
+
await chmod(envFilePath, 0o600);
|
|
197
|
+
return passcode;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function requireValidStage(stage) {
|
|
201
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(stage)) {
|
|
202
|
+
throw new Error("Secrets stage must start with a letter or number and contain only letters, numbers, underscores, and hyphens");
|
|
203
|
+
}
|
|
204
|
+
if (stage.toLowerCase() === GLOBAL_SCOPE) {
|
|
205
|
+
throw new Error(`Secrets stage "${GLOBAL_SCOPE}" is reserved for global secrets`);
|
|
206
|
+
}
|
|
207
|
+
return stage;
|
|
208
|
+
}
|
|
209
|
+
function requireNonemptyPasscode(passcode) {
|
|
210
|
+
if (passcode.length === 0) {
|
|
211
|
+
throw new Error(`${PASSCODE_ENV_NAME} cannot be empty`);
|
|
212
|
+
}
|
|
213
|
+
return passcode;
|
|
214
|
+
}
|
|
215
|
+
function parseEncryptedFile(contents) {
|
|
216
|
+
let value;
|
|
217
|
+
try {
|
|
218
|
+
value = JSON.parse(contents);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
if (value == null || typeof value !== "object" || !("version" in value)) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
const encrypted = value;
|
|
227
|
+
if (encrypted.version !== ENCRYPTION_VERSION ||
|
|
228
|
+
encrypted.kdf?.name !== "scrypt" ||
|
|
229
|
+
typeof encrypted.kdf.salt !== "string" ||
|
|
230
|
+
encrypted.kdf.cost !== SCRYPT_COST ||
|
|
231
|
+
encrypted.kdf.blockSize !== SCRYPT_BLOCK_SIZE ||
|
|
232
|
+
encrypted.kdf.parallelization !== SCRYPT_PARALLELIZATION ||
|
|
233
|
+
encrypted.cipher?.name !== "aes-256-gcm" ||
|
|
234
|
+
typeof encrypted.cipher.iv !== "string" ||
|
|
235
|
+
typeof encrypted.cipher.authTag !== "string" ||
|
|
236
|
+
typeof encrypted.ciphertext !== "string") {
|
|
237
|
+
throw new Error("Unsupported or invalid encrypted secrets file");
|
|
238
|
+
}
|
|
239
|
+
return encrypted;
|
|
240
|
+
}
|
|
241
|
+
async function encryptSecrets(secrets, passcode) {
|
|
242
|
+
const salt = randomBytes(16);
|
|
243
|
+
const iv = randomBytes(12);
|
|
244
|
+
const key = await deriveKey(passcode, salt);
|
|
245
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
246
|
+
const ciphertext = Buffer.concat([
|
|
247
|
+
cipher.update(stringifyEnvFile(secrets), "utf8"),
|
|
248
|
+
cipher.final(),
|
|
249
|
+
]);
|
|
250
|
+
return {
|
|
251
|
+
version: ENCRYPTION_VERSION,
|
|
252
|
+
kdf: {
|
|
253
|
+
name: "scrypt",
|
|
254
|
+
salt: salt.toString("base64"),
|
|
255
|
+
cost: SCRYPT_COST,
|
|
256
|
+
blockSize: SCRYPT_BLOCK_SIZE,
|
|
257
|
+
parallelization: SCRYPT_PARALLELIZATION,
|
|
258
|
+
},
|
|
259
|
+
cipher: {
|
|
260
|
+
name: "aes-256-gcm",
|
|
261
|
+
iv: iv.toString("base64"),
|
|
262
|
+
authTag: cipher.getAuthTag().toString("base64"),
|
|
263
|
+
},
|
|
264
|
+
ciphertext: ciphertext.toString("base64"),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
async function decryptSecrets(encrypted, passcode) {
|
|
268
|
+
try {
|
|
269
|
+
const salt = Buffer.from(encrypted.kdf.salt, "base64");
|
|
270
|
+
const key = await deriveKey(passcode, salt);
|
|
271
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(encrypted.cipher.iv, "base64"));
|
|
272
|
+
decipher.setAuthTag(Buffer.from(encrypted.cipher.authTag, "base64"));
|
|
273
|
+
const plaintext = Buffer.concat([
|
|
274
|
+
decipher.update(Buffer.from(encrypted.ciphertext, "base64")),
|
|
275
|
+
decipher.final(),
|
|
276
|
+
]).toString("utf8");
|
|
277
|
+
return parseEnvFile(plaintext);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
throw new SecretsDecryptionError();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function deriveKey(passcode, salt) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
scrypt(passcode, salt, KEY_LENGTH, {
|
|
286
|
+
N: SCRYPT_COST,
|
|
287
|
+
r: SCRYPT_BLOCK_SIZE,
|
|
288
|
+
p: SCRYPT_PARALLELIZATION,
|
|
289
|
+
}, (error, key) => {
|
|
290
|
+
if (error != null)
|
|
291
|
+
reject(error);
|
|
292
|
+
else
|
|
293
|
+
resolve(key);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function parseEnvFile(contents) {
|
|
298
|
+
const values = {};
|
|
299
|
+
for (const line of contents.split(/\r?\n/)) {
|
|
300
|
+
const trimmed = line.trim();
|
|
301
|
+
if (trimmed.length === 0 || trimmed.startsWith("#"))
|
|
302
|
+
continue;
|
|
303
|
+
const separatorIndex = trimmed.indexOf("=");
|
|
304
|
+
if (separatorIndex === -1)
|
|
305
|
+
continue;
|
|
306
|
+
const key = trimmed.slice(0, separatorIndex).trim();
|
|
307
|
+
const rawValue = trimmed.slice(separatorIndex + 1).trim();
|
|
308
|
+
values[key] = parseEnvValue(rawValue);
|
|
309
|
+
}
|
|
310
|
+
return values;
|
|
311
|
+
}
|
|
312
|
+
function parseEnvValue(value) {
|
|
313
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
314
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
315
|
+
return value.slice(1, -1).replaceAll("\\n", "\n").replaceAll('\\"', '"');
|
|
316
|
+
}
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
function stringifyEnvFile(values) {
|
|
320
|
+
return Object.entries(values)
|
|
321
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
322
|
+
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
|
|
323
|
+
.join("\n")
|
|
324
|
+
.concat("\n");
|
|
325
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
export const SAWS_DIR = path.resolve("./.saws");
|
|
3
|
+
export const BUILD_DIR = path.resolve(SAWS_DIR, "build");
|
|
4
|
+
export const DB_PASSWORD_PARAMETER_NAME = "db-password";
|
|
5
|
+
export const DEV_USER_PASSWORD_PARAMETER_NAME = "dev-user-password";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { promises as fs, constants } from "node:fs";
|
|
2
|
+
import { BUILD_DIR, SAWS_DIR } from "./constants.js";
|
|
3
|
+
export const createCacheDir = async () => {
|
|
4
|
+
// check if the cache dir exists already or not
|
|
5
|
+
// if not create it
|
|
6
|
+
try {
|
|
7
|
+
await fs.access(BUILD_DIR, constants.F_OK);
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
await fs.mkdir(BUILD_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
export const createSawsDir = async () => {
|
|
14
|
+
try {
|
|
15
|
+
await fs.access(SAWS_DIR, constants.F_OK);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
await fs.mkdir(SAWS_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function createFileIfNotExists(path: string, contents: string): Promise<false | undefined>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
export async function createFileIfNotExists(path, contents) {
|
|
3
|
+
try {
|
|
4
|
+
await fs.writeFile(path, contents, { flag: "wx" });
|
|
5
|
+
}
|
|
6
|
+
catch (err) {
|
|
7
|
+
if (typeof err === "object" && err !== null && "code" in err && err.code === "EEXIST") {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
throw err;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RuntimeLogSink } from "../ServiceDefinition.js";
|
|
2
|
+
export interface InstallDependenciesOptions {
|
|
3
|
+
/**
|
|
4
|
+
* The package or workspace root. Defaults to the current working directory.
|
|
5
|
+
*/
|
|
6
|
+
cwd?: string;
|
|
7
|
+
/**
|
|
8
|
+
* A workspace directory, relative to `cwd`.
|
|
9
|
+
*/
|
|
10
|
+
workspace?: string;
|
|
11
|
+
development?: boolean;
|
|
12
|
+
logSink?: RuntimeLogSink;
|
|
13
|
+
serviceName?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function installDependencies(dependencies: string[], options?: InstallDependenciesOptions): Promise<void>;
|
|
16
|
+
export declare function hasDependency(dependency: string, cwd?: string): Promise<boolean>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileExists } from "./file-exists.js";
|
|
5
|
+
export async function installDependencies(dependencies, options = {}) {
|
|
6
|
+
if (dependencies.length === 0)
|
|
7
|
+
return;
|
|
8
|
+
const rootDirectory = path.resolve(options.cwd ?? process.cwd());
|
|
9
|
+
const packageManager = await detectPackageManager(rootDirectory);
|
|
10
|
+
const args = installArguments(packageManager, dependencies, options.development ?? false, options.workspace);
|
|
11
|
+
await new Promise((resolve, reject) => {
|
|
12
|
+
const captureOutput = options.logSink != null;
|
|
13
|
+
const serviceName = options.serviceName ?? "system";
|
|
14
|
+
const child = spawn(packageManager, args, {
|
|
15
|
+
// Workspace-aware package managers must be run from the workspace root.
|
|
16
|
+
// Running npm from within a workspace can install into the root package
|
|
17
|
+
// instead of updating the generated service's package.json.
|
|
18
|
+
cwd: rootDirectory,
|
|
19
|
+
env: process.env,
|
|
20
|
+
stdio: captureOutput ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
21
|
+
});
|
|
22
|
+
child.stdout?.on("data", (chunk) => {
|
|
23
|
+
options.logSink?.({
|
|
24
|
+
serviceName,
|
|
25
|
+
stream: "stdout",
|
|
26
|
+
chunk: chunk.toString("utf8"),
|
|
27
|
+
timestamp: new Date(),
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
child.stderr?.on("data", (chunk) => {
|
|
31
|
+
options.logSink?.({
|
|
32
|
+
serviceName,
|
|
33
|
+
stream: "stderr",
|
|
34
|
+
chunk: chunk.toString("utf8"),
|
|
35
|
+
timestamp: new Date(),
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
child.once("error", (error) => {
|
|
39
|
+
reject(new Error(`Unable to run ${packageManager}: ${error.message}`, { cause: error }));
|
|
40
|
+
});
|
|
41
|
+
child.once("close", (code, signal) => {
|
|
42
|
+
if (code === 0) {
|
|
43
|
+
resolve();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const reason = signal == null ? `exit code ${code ?? "unknown"}` : `signal ${signal}`;
|
|
47
|
+
reject(new Error(`${packageManager} failed to install dependencies with ${reason}`));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export async function hasDependency(dependency, cwd = process.cwd()) {
|
|
52
|
+
try {
|
|
53
|
+
const contents = await readFile(path.resolve(cwd, "package.json"), "utf8");
|
|
54
|
+
const packageJson = JSON.parse(contents);
|
|
55
|
+
return (packageJson.dependencies?.[dependency] != null ||
|
|
56
|
+
packageJson.devDependencies?.[dependency] != null);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code === "ENOENT")
|
|
60
|
+
return false;
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function detectPackageManager(cwd) {
|
|
65
|
+
for (let directory = cwd;; directory = path.dirname(directory)) {
|
|
66
|
+
const packageManager = await readPackageManagerField(directory);
|
|
67
|
+
if (packageManager != null)
|
|
68
|
+
return packageManager;
|
|
69
|
+
for (const [lockfile, manager] of LOCKFILES) {
|
|
70
|
+
if (await fileExists(path.join(directory, lockfile)))
|
|
71
|
+
return manager;
|
|
72
|
+
}
|
|
73
|
+
const parent = path.dirname(directory);
|
|
74
|
+
if (parent === directory)
|
|
75
|
+
return "npm";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function readPackageManagerField(directory) {
|
|
79
|
+
try {
|
|
80
|
+
const contents = await readFile(path.join(directory, "package.json"), "utf8");
|
|
81
|
+
const packageJson = JSON.parse(contents);
|
|
82
|
+
if (typeof packageJson.packageManager !== "string")
|
|
83
|
+
return undefined;
|
|
84
|
+
const name = packageJson.packageManager.split("@", 1)[0];
|
|
85
|
+
return isPackageManager(name) ? name : undefined;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code === "ENOENT")
|
|
89
|
+
return undefined;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function installArguments(packageManager, dependencies, development, workspace) {
|
|
94
|
+
switch (packageManager) {
|
|
95
|
+
case "npm":
|
|
96
|
+
return [
|
|
97
|
+
"install",
|
|
98
|
+
...(workspace == null ? [] : ["--workspace", workspace]),
|
|
99
|
+
development ? "--save-dev" : "--save-prod",
|
|
100
|
+
...dependencies,
|
|
101
|
+
];
|
|
102
|
+
case "pnpm":
|
|
103
|
+
return [
|
|
104
|
+
...(workspace == null ? [] : ["--filter", workspace]),
|
|
105
|
+
"add",
|
|
106
|
+
...(development ? ["--dev"] : []),
|
|
107
|
+
...dependencies,
|
|
108
|
+
];
|
|
109
|
+
case "yarn":
|
|
110
|
+
return workspace == null
|
|
111
|
+
? ["add", ...(development ? ["--dev"] : []), ...dependencies]
|
|
112
|
+
: ["workspace", workspace, "add", ...(development ? ["--dev"] : []), ...dependencies];
|
|
113
|
+
case "bun":
|
|
114
|
+
return [
|
|
115
|
+
"add",
|
|
116
|
+
...(workspace == null ? [] : ["--filter", workspace]),
|
|
117
|
+
...(development ? ["--dev"] : []),
|
|
118
|
+
...dependencies,
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function isPackageManager(value) {
|
|
123
|
+
return value === "npm" || value === "pnpm" || value === "yarn" || value === "bun";
|
|
124
|
+
}
|
|
125
|
+
const LOCKFILES = [
|
|
126
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
127
|
+
["yarn.lock", "yarn"],
|
|
128
|
+
["bun.lock", "bun"],
|
|
129
|
+
["bun.lockb", "bun"],
|
|
130
|
+
["package-lock.json", "npm"],
|
|
131
|
+
["npm-shrinkwrap.json", "npm"],
|
|
132
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function fileExists(filePath: string): Promise<boolean>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const generateToken: () => Promise<string>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
export const generateToken = async () => {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
crypto.randomBytes(20, (err, buffer) => {
|
|
5
|
+
if (err) {
|
|
6
|
+
reject(err);
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
resolve(buffer.toString("hex"));
|
|
10
|
+
return;
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const getProjectName: () => string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function listFiles(directory: string): Promise<string[]>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export async function listFiles(directory) {
|
|
4
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
5
|
+
const files = [];
|
|
6
|
+
for (const entry of entries) {
|
|
7
|
+
const fullPath = path.join(directory, entry.name);
|
|
8
|
+
if (entry.isDirectory()) {
|
|
9
|
+
files.push(...(await listFiles(fullPath)));
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (entry.isFile()) {
|
|
13
|
+
files.push(fullPath);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return files;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const onProcessExit: (callback: () => void) => void;
|