@flowscripter/dynamic-cli-framework 12.1.0 → 12.2.0
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/src/cli/BaseCLI.d.ts.map +1 -1
- package/dist/src/cli/BaseCLI.js +1 -1
- package/dist/src/runtime/runner.d.ts.map +1 -1
- package/dist/src/runtime/runner.js +4 -1
- package/dist/src/service/configuration/ConfigurationServiceProvider.d.ts +23 -13
- package/dist/src/service/configuration/ConfigurationServiceProvider.d.ts.map +1 -1
- package/dist/src/service/configuration/ConfigurationServiceProvider.js +69 -179
- package/dist/src/service/configuration/DefaultConfigurationService.d.ts +78 -0
- package/dist/src/service/configuration/DefaultConfigurationService.d.ts.map +1 -0
- package/dist/src/service/configuration/DefaultConfigurationService.js +296 -0
- package/dist/src/service/configuration/DefaultKeyValueService.d.ts.map +1 -1
- package/dist/src/service/configuration/DefaultKeyValueService.js +7 -3
- package/dist/src/service/configuration/DefaultSecretService.d.ts +17 -2
- package/dist/src/service/configuration/DefaultSecretService.d.ts.map +1 -1
- package/dist/src/service/configuration/DefaultSecretService.js +44 -5
- package/dist/src/service/configuration/KeyValueServiceProvider.d.ts +3 -1
- package/dist/src/service/configuration/KeyValueServiceProvider.d.ts.map +1 -1
- package/dist/src/service/configuration/KeyValueServiceProvider.js +6 -3
- package/dist/src/service/configuration/command/ConfigDumpSubCommand.d.ts +3 -7
- package/dist/src/service/configuration/command/ConfigDumpSubCommand.d.ts.map +1 -1
- package/dist/src/service/configuration/command/ConfigDumpSubCommand.js +5 -10
- package/dist/src/service/configuration/command/ConfigGetSubCommand.d.ts +16 -0
- package/dist/src/service/configuration/command/ConfigGetSubCommand.d.ts.map +1 -0
- package/dist/src/service/configuration/command/ConfigGetSubCommand.js +137 -0
- package/dist/src/service/configuration/command/ConfigSetSubCommand.d.ts +18 -0
- package/dist/src/service/configuration/command/ConfigSetSubCommand.d.ts.map +1 -0
- package/dist/src/service/configuration/command/ConfigSetSubCommand.js +289 -0
- package/dist/src/service/configuration/command/ConfigUnsetSubCommand.d.ts +15 -0
- package/dist/src/service/configuration/command/ConfigUnsetSubCommand.d.ts.map +1 -0
- package/dist/src/service/configuration/command/ConfigUnsetSubCommand.js +101 -0
- package/dist/src/service/configuration/configTargets.d.ts +91 -0
- package/dist/src/service/configuration/configTargets.d.ts.map +1 -0
- package/dist/src/service/configuration/configTargets.js +348 -0
- package/dist/src/service/configuration/resolveSecrets.d.ts.map +1 -1
- package/dist/src/service/configuration/resolveSecrets.js +7 -1
- package/package.json +1 -1
- package/src/cli/BaseCLI.ts +1 -0
- package/src/runtime/runner.ts +4 -1
- package/src/service/configuration/ConfigurationServiceProvider.ts +95 -227
- package/src/service/configuration/DefaultConfigurationService.ts +378 -0
- package/src/service/configuration/DefaultKeyValueService.ts +9 -3
- package/src/service/configuration/DefaultSecretService.ts +51 -8
- package/src/service/configuration/KeyValueServiceProvider.ts +7 -2
- package/src/service/configuration/command/ConfigDumpSubCommand.ts +6 -10
- package/src/service/configuration/command/ConfigGetSubCommand.ts +198 -0
- package/src/service/configuration/command/ConfigSetSubCommand.ts +401 -0
- package/src/service/configuration/command/ConfigUnsetSubCommand.ts +126 -0
- package/src/service/configuration/configTargets.ts +481 -0
- package/src/service/configuration/resolveSecrets.ts +7 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import type { Stats } from "node:fs";
|
|
5
|
+
import {
|
|
6
|
+
SECRET_SENTINEL_PREFIX,
|
|
7
|
+
type CommandRegistry,
|
|
8
|
+
type ConfigurationService,
|
|
9
|
+
type SingleValueType,
|
|
10
|
+
type ValueNode,
|
|
11
|
+
type Values,
|
|
12
|
+
} from "@flowscripter/dynamic-cli-framework-api";
|
|
13
|
+
import getLogger from "../../util/logger.ts";
|
|
14
|
+
import {
|
|
15
|
+
collectSentinels,
|
|
16
|
+
getConfigTargets,
|
|
17
|
+
maskDefault,
|
|
18
|
+
type ConfigTarget,
|
|
19
|
+
} from "./configTargets.ts";
|
|
20
|
+
import type DefaultSecretService from "./DefaultSecretService.ts";
|
|
21
|
+
import type ConfigFormat from "./format/ConfigFormat.ts";
|
|
22
|
+
import type { ConfigData } from "./format/ConfigFormat.ts";
|
|
23
|
+
import resolveSecrets from "./resolveSecrets.ts";
|
|
24
|
+
|
|
25
|
+
const logger = getLogger("DefaultConfigurationService");
|
|
26
|
+
|
|
27
|
+
export type KeyValueServiceScopeType = "command" | "service";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Owns the configuration data read from (and written back to) the configuration file: the default
|
|
31
|
+
* command argument values and the raw per-scope key-value data.
|
|
32
|
+
*
|
|
33
|
+
* The `config` group commands are constructed with this instance. It must never be registered in the
|
|
34
|
+
* {@link Context}: it exposes every command's defaults, the raw key-value data of every scope and secret
|
|
35
|
+
* resolution. Only a location-only view is registered under {@link CONFIGURATION_SERVICE_ID}.
|
|
36
|
+
*/
|
|
37
|
+
export default class DefaultConfigurationService implements ConfigurationService {
|
|
38
|
+
public readonly envVarsEnabled: boolean;
|
|
39
|
+
public readonly configFormat: ConfigFormat;
|
|
40
|
+
|
|
41
|
+
// the location of the currently managed configuration data
|
|
42
|
+
public configLocation: string | undefined;
|
|
43
|
+
|
|
44
|
+
// the configuration data used when setting command defaults. SingleValueType is for
|
|
45
|
+
// GlobalCommandArgument values, Values is for SubCommandArgument values.
|
|
46
|
+
public defaultsData: Map<string, Values | SingleValueType> = new Map();
|
|
47
|
+
|
|
48
|
+
// the format specific handle for the text last read or written, used to preserve comments and layout on write
|
|
49
|
+
#configHandle: unknown;
|
|
50
|
+
|
|
51
|
+
#commandKeyValueData = new Map<string, Map<string, ValueNode>>();
|
|
52
|
+
#serviceKeyValueData = new Map<string, Map<string, ValueNode>>();
|
|
53
|
+
|
|
54
|
+
// used only to resolve secret sentinels embedded in default command argument values and to store
|
|
55
|
+
// SECRET values set via the `config` group commands
|
|
56
|
+
#defaultsSecretService: DefaultSecretService | undefined;
|
|
57
|
+
|
|
58
|
+
readonly #commandRegistry: CommandRegistry | undefined;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param envVarsEnabled whether environment variables may override stored default values.
|
|
62
|
+
* @param configFormat the syntax of the configuration file.
|
|
63
|
+
* @param commandRegistry optional registry of all commands, required for {@link getConfigTargets}.
|
|
64
|
+
*/
|
|
65
|
+
public constructor(
|
|
66
|
+
envVarsEnabled: boolean,
|
|
67
|
+
configFormat: ConfigFormat,
|
|
68
|
+
commandRegistry?: CommandRegistry,
|
|
69
|
+
) {
|
|
70
|
+
this.envVarsEnabled = envVarsEnabled;
|
|
71
|
+
this.configFormat = configFormat;
|
|
72
|
+
this.#commandRegistry = commandRegistry;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public setConfigLocation(location: string): void {
|
|
76
|
+
this.configLocation = location;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public setDefaultsSecretService(secretService: DefaultSecretService): void {
|
|
80
|
+
this.#defaultsSecretService = secretService;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve any secret sentinels within the value using the OS secret store (if enabled).
|
|
85
|
+
*
|
|
86
|
+
* @param commandKey the key of the command under `defaults` the value belongs to: a sentinel
|
|
87
|
+
* may only refer to a secret stored for this command.
|
|
88
|
+
*/
|
|
89
|
+
public async resolveDefaultSecrets<T>(value: T, commandKey: string): Promise<T> {
|
|
90
|
+
if (this.#defaultsSecretService === undefined) {
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
const secretService = this.#defaultsSecretService;
|
|
94
|
+
return await resolveSecrets<T>(value, async (bunSecretName) => {
|
|
95
|
+
if (!secretService.ownsSecretName(bunSecretName, commandKey)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Secret sentinel '${SECRET_SENTINEL_PREFIX}${bunSecretName}' does not refer to a secret stored for command: '${commandKey}'`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const secretValue = await secretService.getSecret(bunSecretName);
|
|
101
|
+
if (secretValue === null) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Secret not found in OS secret store for sentinel: '${SECRET_SENTINEL_PREFIX}${bunSecretName}'`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return secretValue;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get-or-create the underlying, raw {@link ValueNode} `Map` for the given scope.
|
|
112
|
+
*
|
|
113
|
+
* @param scopeType whether `scopeKey` is a command name or a service/task ID.
|
|
114
|
+
* @param scopeKey the command name or service/task ID to scope the key-value data to.
|
|
115
|
+
*/
|
|
116
|
+
public getKeyValueData(
|
|
117
|
+
scopeType: KeyValueServiceScopeType,
|
|
118
|
+
scopeKey: string,
|
|
119
|
+
): Map<string, ValueNode> {
|
|
120
|
+
const keyValueData =
|
|
121
|
+
scopeType === "command" ? this.#commandKeyValueData : this.#serviceKeyValueData;
|
|
122
|
+
if (!keyValueData.has(scopeKey)) {
|
|
123
|
+
keyValueData.set(scopeKey, new Map());
|
|
124
|
+
}
|
|
125
|
+
return keyValueData.get(scopeKey)!;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Write the configuration file, if `isDirty` is true.
|
|
130
|
+
*
|
|
131
|
+
* @param isDirty whether any scoped KeyValueService has unwritten changes.
|
|
132
|
+
*/
|
|
133
|
+
public async flushIfDirty(isDirty: boolean): Promise<void> {
|
|
134
|
+
if (!isDirty) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (this.configLocation === undefined) {
|
|
138
|
+
throw new Error("Attempt to write updated config with no configLocation set");
|
|
139
|
+
}
|
|
140
|
+
await this.#writeConfig(this.configLocation);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Determine the configuration location (defaulting to `$HOME/.<application_name>.<format extension>`)
|
|
145
|
+
* and read the configuration from it if it exists.
|
|
146
|
+
*
|
|
147
|
+
* @param applicationName the name of the CLI.
|
|
148
|
+
*/
|
|
149
|
+
public async init(applicationName: string): Promise<void> {
|
|
150
|
+
let isDefault = false;
|
|
151
|
+
if (this.configLocation === undefined) {
|
|
152
|
+
isDefault = true;
|
|
153
|
+
const home = process.env["HOME"];
|
|
154
|
+
if (home) {
|
|
155
|
+
this.configLocation = path.join(
|
|
156
|
+
home,
|
|
157
|
+
`.${applicationName.replace(/\W/g, "")}.${this.configFormat.fileExtension}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (this.configLocation === undefined) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
let fileInfo: Stats;
|
|
165
|
+
try {
|
|
166
|
+
fileInfo = await fs.lstat(this.configLocation);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const error = err as Error;
|
|
169
|
+
if (isDefault) {
|
|
170
|
+
logger.debug(
|
|
171
|
+
"Default config file location: %s does not exist or is not visible - ignoring: %s",
|
|
172
|
+
this.configLocation,
|
|
173
|
+
error.message,
|
|
174
|
+
);
|
|
175
|
+
return;
|
|
176
|
+
} else {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Config file location: '${this.configLocation}' doesn't exist or not visible: ${error.message}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (fileInfo.isDirectory()) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Config file location: '${this.configLocation}' is a directory and not a file!`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await this.#readConfig(this.configLocation);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async #readConfig(location: string): Promise<void> {
|
|
192
|
+
try {
|
|
193
|
+
logger.debug("Reading config from: %s", location);
|
|
194
|
+
|
|
195
|
+
const data = await fs.readFile(location);
|
|
196
|
+
const { data: config, handle } = this.configFormat.parse(data.toString());
|
|
197
|
+
this.#configHandle = handle;
|
|
198
|
+
|
|
199
|
+
this.defaultsData.clear();
|
|
200
|
+
this.#commandKeyValueData.clear();
|
|
201
|
+
this.#serviceKeyValueData.clear();
|
|
202
|
+
if (config.defaults !== undefined) {
|
|
203
|
+
const defaults = config.defaults;
|
|
204
|
+
Object.keys(defaults).forEach((commandName) => {
|
|
205
|
+
this.defaultsData.set(commandName, defaults[commandName] as Values);
|
|
206
|
+
logger.debug("Set default argument values for command name: %s", commandName);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (config["key-values"] !== undefined) {
|
|
210
|
+
const keyValues = config["key-values"];
|
|
211
|
+
if (keyValues.commands !== undefined) {
|
|
212
|
+
const commandKeyValues = keyValues.commands;
|
|
213
|
+
Object.keys(commandKeyValues).forEach((commandName) => {
|
|
214
|
+
this.#commandKeyValueData.set(
|
|
215
|
+
commandName,
|
|
216
|
+
new Map(Object.entries(commandKeyValues[commandName])),
|
|
217
|
+
);
|
|
218
|
+
logger.debug("Set default argument values for command name: %s", commandName);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (keyValues.services !== undefined) {
|
|
222
|
+
const serviceKeyValues = keyValues.services;
|
|
223
|
+
Object.keys(serviceKeyValues).forEach((serviceId) => {
|
|
224
|
+
this.#serviceKeyValueData.set(
|
|
225
|
+
serviceId,
|
|
226
|
+
new Map(Object.entries(serviceKeyValues[serviceId])),
|
|
227
|
+
);
|
|
228
|
+
logger.debug("Set default argument values for service ID: %s", serviceId);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
throw new Error(`Failed to read config at location: '${this.configLocation}': ${err}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async #writeConfig(location: string): Promise<void> {
|
|
238
|
+
logger.debug("Writing config to: %s", location);
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const config = this.#getConfigData();
|
|
242
|
+
const text =
|
|
243
|
+
this.#configHandle === undefined
|
|
244
|
+
? this.configFormat.serialize(config)
|
|
245
|
+
: this.configFormat.apply(this.#configHandle, config);
|
|
246
|
+
// owner-only when the file is created; an existing file keeps its mode
|
|
247
|
+
await fs.writeFile(location, text, { mode: 0o600 });
|
|
248
|
+
this.#configHandle = this.configFormat.parse(text).handle;
|
|
249
|
+
} catch (err) {
|
|
250
|
+
throw new Error(`Failed to write config at location: '${this.configLocation}': ${err}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
public getConfigString(): string {
|
|
255
|
+
return this.configFormat.serialize(this.#getConfigData());
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The configuration text with secret sentinels and the values of SECRET typed arguments of
|
|
260
|
+
* known commands masked.
|
|
261
|
+
*/
|
|
262
|
+
public getMaskedConfigString(): string {
|
|
263
|
+
const data = this.#getConfigData();
|
|
264
|
+
const targets = this.getConfigTargets();
|
|
265
|
+
const masked: ConfigData = maskDefault(undefined, data) as ConfigData;
|
|
266
|
+
if (data.defaults !== undefined) {
|
|
267
|
+
masked.defaults = Object.fromEntries(
|
|
268
|
+
Object.entries(data.defaults).map(([key, value]) => [
|
|
269
|
+
key,
|
|
270
|
+
maskDefault(targets.get(key), value),
|
|
271
|
+
]),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return this.configFormat.serialize(masked);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The commands whose defaults can be managed via the `config` group commands.
|
|
279
|
+
*/
|
|
280
|
+
public getConfigTargets(): Map<string, ConfigTarget> {
|
|
281
|
+
return this.#commandRegistry === undefined
|
|
282
|
+
? new Map()
|
|
283
|
+
: getConfigTargets(this.#commandRegistry);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Replace (or remove, if `build` returns `undefined`) the stored defaults of a command and write the
|
|
288
|
+
* configuration file immediately. Memory, file and secret store are left unchanged if anything fails.
|
|
289
|
+
*
|
|
290
|
+
* @param commandKey the key of the command under `defaults`.
|
|
291
|
+
* @param secrets plain text secrets to store in the OS secret store, keyed to be used for naming.
|
|
292
|
+
* @param build creates the new value given the sentinels (one per secret) to reference the stored secrets.
|
|
293
|
+
*/
|
|
294
|
+
public async commitDefault(
|
|
295
|
+
commandKey: string,
|
|
296
|
+
secrets: ReadonlyArray<{ key: ReadonlyArray<string>; value: string }>,
|
|
297
|
+
build: (sentinels: ReadonlyArray<string>) => Values | SingleValueType | undefined,
|
|
298
|
+
): Promise<void> {
|
|
299
|
+
if (this.configLocation === undefined) {
|
|
300
|
+
throw new Error("Unable to write config: no config location is set and $HOME is not defined");
|
|
301
|
+
}
|
|
302
|
+
if (secrets.length > 0 && this.#defaultsSecretService === undefined) {
|
|
303
|
+
throw new Error("Unable to store a SECRET value: the secret service is not enabled");
|
|
304
|
+
}
|
|
305
|
+
const secretService = this.#defaultsSecretService;
|
|
306
|
+
const previous = this.defaultsData.get(commandKey);
|
|
307
|
+
const restores: Array<() => Promise<unknown>> = [];
|
|
308
|
+
const sentinels: Array<string> = [];
|
|
309
|
+
try {
|
|
310
|
+
for (const { key, value } of secrets) {
|
|
311
|
+
const name = secretService!.secretName(key);
|
|
312
|
+
const old = await secretService!.getSecret(name);
|
|
313
|
+
await secretService!.setSecret(key, value);
|
|
314
|
+
restores.push(() =>
|
|
315
|
+
old === null ? secretService!.deleteSecret(name) : secretService!.setSecret(key, old),
|
|
316
|
+
);
|
|
317
|
+
sentinels.push(`${SECRET_SENTINEL_PREFIX}${name}`);
|
|
318
|
+
}
|
|
319
|
+
const next = build(sentinels);
|
|
320
|
+
if (next === undefined) {
|
|
321
|
+
this.defaultsData.delete(commandKey);
|
|
322
|
+
} else {
|
|
323
|
+
this.defaultsData.set(commandKey, next);
|
|
324
|
+
}
|
|
325
|
+
await this.#writeConfig(this.configLocation);
|
|
326
|
+
const kept = new Set(collectSentinels(next));
|
|
327
|
+
for (const sentinel of collectSentinels(previous)) {
|
|
328
|
+
if (!kept.has(sentinel) && secretService !== undefined) {
|
|
329
|
+
await secretService.deleteSecret(sentinel.slice(SECRET_SENTINEL_PREFIX.length));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} catch (err) {
|
|
333
|
+
if (previous === undefined) {
|
|
334
|
+
this.defaultsData.delete(commandKey);
|
|
335
|
+
} else {
|
|
336
|
+
this.defaultsData.set(commandKey, previous);
|
|
337
|
+
}
|
|
338
|
+
for (const restore of restores.reverse()) {
|
|
339
|
+
await restore().catch(() => undefined);
|
|
340
|
+
}
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#getConfigData(): ConfigData {
|
|
346
|
+
const config: {
|
|
347
|
+
defaults?: Record<string, Values | SingleValueType>;
|
|
348
|
+
"key-values"?: {
|
|
349
|
+
commands?: Record<string, Record<string, ValueNode>>;
|
|
350
|
+
services?: Record<string, Record<string, ValueNode>>;
|
|
351
|
+
};
|
|
352
|
+
} = {};
|
|
353
|
+
|
|
354
|
+
if (this.defaultsData.size > 0) {
|
|
355
|
+
config.defaults = Object.fromEntries(this.defaultsData);
|
|
356
|
+
}
|
|
357
|
+
if (this.#commandKeyValueData.size > 0 || this.#serviceKeyValueData.size > 0) {
|
|
358
|
+
config["key-values"] = {};
|
|
359
|
+
if (this.#commandKeyValueData.size > 0) {
|
|
360
|
+
config["key-values"].commands = {};
|
|
361
|
+
for (const [commandName, keyValueData] of this.#commandKeyValueData.entries()) {
|
|
362
|
+
if (keyValueData.size > 0) {
|
|
363
|
+
config["key-values"].commands[commandName] = Object.fromEntries(keyValueData);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (this.#serviceKeyValueData.size > 0) {
|
|
368
|
+
config["key-values"].services = {};
|
|
369
|
+
for (const [serviceId, keyValueData] of this.#serviceKeyValueData.entries()) {
|
|
370
|
+
if (keyValueData.size > 0) {
|
|
371
|
+
config["key-values"].services[serviceId] = Object.fromEntries(keyValueData);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return config;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
@@ -57,6 +57,11 @@ export default class DefaultKeyValueService implements KeyValueService {
|
|
|
57
57
|
if (!this.#secretService) {
|
|
58
58
|
throw new Error("Secret sentinel found but no secret service is available");
|
|
59
59
|
}
|
|
60
|
+
if (!this.#secretService.ownsSecretName(bunSecretName)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Secret sentinel for key: '${key}' refers to a secret outside of this scope`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
60
65
|
const secretValue = await this.#secretService.getSecret(bunSecretName);
|
|
61
66
|
if (secretValue === null) {
|
|
62
67
|
throw new Error(`Secret not found in OS secret store for key: '${key}'`);
|
|
@@ -89,9 +94,8 @@ export default class DefaultKeyValueService implements KeyValueService {
|
|
|
89
94
|
if (!this.#secretService) {
|
|
90
95
|
throw new Error("Attempt to set a secret but no secret service is available");
|
|
91
96
|
}
|
|
92
|
-
const secretName = [key, ...path].join("_");
|
|
93
97
|
const bunSecretName = await this.#secretService.setSecret(
|
|
94
|
-
|
|
98
|
+
[key, ...path],
|
|
95
99
|
JSON.stringify(value.value),
|
|
96
100
|
);
|
|
97
101
|
return SECRET_SENTINEL_PREFIX + bunSecretName;
|
|
@@ -129,7 +133,9 @@ export default class DefaultKeyValueService implements KeyValueService {
|
|
|
129
133
|
if (typeof value === "string" && value.startsWith(SECRET_SENTINEL_PREFIX)) {
|
|
130
134
|
if (this.#secretService) {
|
|
131
135
|
const bunSecretName = value.slice(SECRET_SENTINEL_PREFIX.length);
|
|
132
|
-
|
|
136
|
+
if (this.#secretService.ownsSecretName(bunSecretName)) {
|
|
137
|
+
await this.#secretService.deleteSecret(bunSecretName);
|
|
138
|
+
}
|
|
133
139
|
}
|
|
134
140
|
return;
|
|
135
141
|
}
|
|
@@ -10,28 +10,71 @@ export interface SecretsApi {
|
|
|
10
10
|
delete(options: { service: string; name: string }): Promise<boolean>;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Injective escape of a name component to [A-Za-z0-9_]: alphanumerics are kept and every other
|
|
15
|
+
* character (including `_`) becomes `_<UPPERCASE HEX CODE POINT>_`.
|
|
16
|
+
*/
|
|
17
|
+
function escapeComponent(value: string): string {
|
|
18
|
+
let result = "";
|
|
19
|
+
for (const char of value) {
|
|
20
|
+
result += /[a-zA-Z0-9]/.test(char)
|
|
21
|
+
? char
|
|
22
|
+
: `_${char.codePointAt(0)!.toString(16).toUpperCase()}_`;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// `Z` is not a hex digit so `_Z` can never be mistaken for the start of an escape.
|
|
28
|
+
const SEPARATOR = "_Z";
|
|
29
|
+
|
|
13
30
|
export default class DefaultSecretService implements SecretService {
|
|
14
31
|
readonly #serviceName: string;
|
|
15
32
|
readonly #secrets: SecretsApi;
|
|
16
|
-
readonly #scope: string
|
|
33
|
+
readonly #scope: ReadonlyArray<string>;
|
|
17
34
|
|
|
18
|
-
|
|
19
|
-
|
|
35
|
+
/**
|
|
36
|
+
* @param scope the components identifying the scope, e.g. `["command", "a"]`.
|
|
37
|
+
*/
|
|
38
|
+
constructor(serviceName: string, scope: ReadonlyArray<string>, secretsApi?: SecretsApi) {
|
|
39
|
+
this.#serviceName = serviceName.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
20
40
|
if (this.#serviceName.length > MAX_NAME_LENGTH) {
|
|
21
41
|
throw new Error(
|
|
22
42
|
`Service name exceeds ${MAX_NAME_LENGTH} characters after sanitization: '${this.#serviceName}'`,
|
|
23
43
|
);
|
|
24
44
|
}
|
|
25
|
-
this.#scope =
|
|
45
|
+
this.#scope = scope;
|
|
26
46
|
this.#secrets = secretsApi ?? bunSecrets;
|
|
27
47
|
}
|
|
28
48
|
|
|
29
|
-
|
|
30
|
-
return
|
|
49
|
+
#scopePrefix(): string {
|
|
50
|
+
return this.#scope.map((c) => escapeComponent(c) + SEPARATOR).join("");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The name under which a secret with the provided key is stored. The scope and key components
|
|
55
|
+
* are escaped and delimited so that distinct (scope, key) pairs never produce the same name.
|
|
56
|
+
*
|
|
57
|
+
* @param key a single key or the components of a key, e.g. a key and a path within its value.
|
|
58
|
+
*/
|
|
59
|
+
public secretName(key: string | ReadonlyArray<string>): string {
|
|
60
|
+
const parts = typeof key === "string" ? [key] : key;
|
|
61
|
+
return this.#scopePrefix() + parts.map(escapeComponent).join(SEPARATOR);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether the stored secret name was created by this scope, and, if `firstKeyComponent` is
|
|
66
|
+
* provided, with that value as the first component of its key.
|
|
67
|
+
*/
|
|
68
|
+
public ownsSecretName(bunSecretName: string, firstKeyComponent?: string): boolean {
|
|
69
|
+
if (firstKeyComponent === undefined) {
|
|
70
|
+
return bunSecretName.startsWith(this.#scopePrefix());
|
|
71
|
+
}
|
|
72
|
+
const base = this.#scopePrefix() + escapeComponent(firstKeyComponent);
|
|
73
|
+
return bunSecretName === base || bunSecretName.startsWith(base + SEPARATOR);
|
|
31
74
|
}
|
|
32
75
|
|
|
33
|
-
public async setSecret(key: string
|
|
34
|
-
const name = this
|
|
76
|
+
public async setSecret(key: string | ReadonlyArray<string>, value: string): Promise<string> {
|
|
77
|
+
const name = this.secretName(key);
|
|
35
78
|
if (name.length > MAX_NAME_LENGTH) {
|
|
36
79
|
throw new Error(`Secret name exceeds ${MAX_NAME_LENGTH} characters: '${name}'`);
|
|
37
80
|
}
|
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
} from "@flowscripter/dynamic-cli-framework-api";
|
|
9
9
|
import { KEY_VALUE_SERVICE_ID, SHUTDOWN_SERVICE_ID } from "@flowscripter/dynamic-cli-framework-api";
|
|
10
10
|
import DefaultKeyValueService from "./DefaultKeyValueService.ts";
|
|
11
|
-
import DefaultSecretService from "./DefaultSecretService.ts";
|
|
11
|
+
import DefaultSecretService, { type SecretsApi } from "./DefaultSecretService.ts";
|
|
12
12
|
import type ConfigurationServiceProvider from "./ConfigurationServiceProvider.ts";
|
|
13
13
|
import type { KeyValueServiceScopeType } from "./ConfigurationServiceProvider.ts";
|
|
14
14
|
import type DefaultContext from "../../runtime/DefaultContext.ts";
|
|
@@ -118,6 +118,8 @@ export default class KeyValueServiceProvider implements ServiceProvider {
|
|
|
118
118
|
readonly #commandScopedKeyValueServices = new Map<string, DefaultKeyValueService>();
|
|
119
119
|
readonly #serviceScopedKeyValueServices = new Map<string, DefaultKeyValueService>();
|
|
120
120
|
|
|
121
|
+
readonly #secretsApi: SecretsApi | undefined;
|
|
122
|
+
|
|
121
123
|
// used to construct scoped DefaultSecretService instances on demand.
|
|
122
124
|
#cliConfigName: string | undefined;
|
|
123
125
|
|
|
@@ -129,12 +131,14 @@ export default class KeyValueServiceProvider implements ServiceProvider {
|
|
|
129
131
|
* backs this provider's data - an internal wiring reference, never exposed further.
|
|
130
132
|
* @param keyValueServiceEnabled optionally provide a {@link KeyValueService} implementation: `configurationServiceProvider.configEnabled` must be true in this case
|
|
131
133
|
* @param secretServiceEnabled optionally enable OS-native secret storage via Bun.secrets: `configurationServiceProvider.configEnabled` must be true in this case
|
|
134
|
+
* @param secretsApi optionally override the OS secret store.
|
|
132
135
|
*/
|
|
133
136
|
public constructor(
|
|
134
137
|
servicePriority: number,
|
|
135
138
|
configurationServiceProvider: ConfigurationServiceProvider,
|
|
136
139
|
keyValueServiceEnabled = false,
|
|
137
140
|
secretServiceEnabled = false,
|
|
141
|
+
secretsApi?: SecretsApi,
|
|
138
142
|
) {
|
|
139
143
|
if (!configurationServiceProvider.configEnabled && keyValueServiceEnabled) {
|
|
140
144
|
throw new Error("configEnabled must be true if keyValueServiceEnabled is true");
|
|
@@ -145,6 +149,7 @@ export default class KeyValueServiceProvider implements ServiceProvider {
|
|
|
145
149
|
this.servicePriority = servicePriority;
|
|
146
150
|
this.#configurationServiceProvider = configurationServiceProvider;
|
|
147
151
|
this.keyValueServiceEnabled = keyValueServiceEnabled;
|
|
152
|
+
this.#secretsApi = secretsApi;
|
|
148
153
|
this.secretServiceEnabled = secretServiceEnabled;
|
|
149
154
|
}
|
|
150
155
|
|
|
@@ -191,7 +196,7 @@ export default class KeyValueServiceProvider implements ServiceProvider {
|
|
|
191
196
|
const keyValueData = this.#configurationServiceProvider.getKeyValueData(scopeType, scopeKey);
|
|
192
197
|
|
|
193
198
|
const secretService = this.secretServiceEnabled
|
|
194
|
-
? new DefaultSecretService(this.#cliConfigName!,
|
|
199
|
+
? new DefaultSecretService(this.#cliConfigName!, [scopeType, scopeKey], this.#secretsApi)
|
|
195
200
|
: undefined;
|
|
196
201
|
const scopedService = new DefaultKeyValueService(keyValueData, secretService, () =>
|
|
197
202
|
this.#flushDirtyScopes(),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type DefaultConfigurationService from "../DefaultConfigurationService.ts";
|
|
1
2
|
import type { Context } from "@flowscripter/dynamic-cli-framework-api";
|
|
2
3
|
import type { Option } from "@flowscripter/dynamic-cli-framework-api";
|
|
3
4
|
import type { Positional } from "@flowscripter/dynamic-cli-framework-api";
|
|
@@ -9,12 +10,7 @@ import { SYNTAX_HIGHLIGHTER_SERVICE_ID } from "@flowscripter/dynamic-cli-framewo
|
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Member of the `config` group which dumps the configuration loaded by
|
|
12
|
-
* {@link
|
|
13
|
-
*
|
|
14
|
-
* Takes a direct reference to {@link ConfigurationServiceProvider.getConfigString}, wired by the
|
|
15
|
-
* provider that constructs this command, rather than looking it up via {@link Context} -
|
|
16
|
-
* deliberately, since a full config dump is not something every command/task should be able to
|
|
17
|
-
* reach through a general lookup (see {@link ConfigurationService}'s doc comment).
|
|
13
|
+
* {@link DefaultConfigurationService}, with secrets masked.
|
|
18
14
|
*/
|
|
19
15
|
export default class ConfigDumpSubCommand implements SubCommand {
|
|
20
16
|
readonly name = "dump";
|
|
@@ -22,10 +18,10 @@ export default class ConfigDumpSubCommand implements SubCommand {
|
|
|
22
18
|
readonly options: ReadonlyArray<Option> = [];
|
|
23
19
|
readonly positionals: ReadonlyArray<Positional> = [];
|
|
24
20
|
|
|
25
|
-
readonly #
|
|
21
|
+
readonly #configurationService: DefaultConfigurationService;
|
|
26
22
|
|
|
27
|
-
public constructor(
|
|
28
|
-
this.#
|
|
23
|
+
public constructor(configurationService: DefaultConfigurationService) {
|
|
24
|
+
this.#configurationService = configurationService;
|
|
29
25
|
}
|
|
30
26
|
|
|
31
27
|
public async execute(context: Context): Promise<void> {
|
|
@@ -35,7 +31,7 @@ export default class ConfigDumpSubCommand implements SubCommand {
|
|
|
35
31
|
) as SyntaxHighlighterService;
|
|
36
32
|
|
|
37
33
|
await printerService.print(
|
|
38
|
-
`${syntaxHighlighterService.highlight(this.#
|
|
34
|
+
`${syntaxHighlighterService.highlight(this.#configurationService.getMaskedConfigString(), "json")}\n`,
|
|
39
35
|
);
|
|
40
36
|
}
|
|
41
37
|
}
|