@fuzdev/fuz_app 0.18.0 → 0.19.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/actions/action_event.d.ts.map +1 -1
- package/dist/actions/action_event.js +2 -9
- package/dist/actions/socket.svelte.d.ts +20 -0
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +59 -0
- package/dist/env/dotenv.d.ts +17 -0
- package/dist/env/dotenv.d.ts.map +1 -1
- package/dist/env/dotenv.js +107 -8
- package/dist/env/update_env_variable.d.ts +36 -0
- package/dist/env/update_env_variable.d.ts.map +1 -0
- package/dist/env/update_env_variable.js +160 -0
- package/dist/testing/mock_fs.d.ts +21 -0
- package/dist/testing/mock_fs.d.ts.map +1 -0
- package/dist/testing/mock_fs.js +37 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action_event.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_event.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAC,gBAAgB,EAAc,eAAe,EAAC,MAAM,kBAAkB,CAAC;AAWpF,OAAO,KAAK,EACX,cAAc,EACd,sBAAsB,EACtB,mBAAmB,EAEnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAC,sBAAsB,EAAE,eAAe,EAAC,MAAM,yBAAyB,CAAC;AACrF,OAAO,EAAkB,KAAK,oBAAoB,EAAC,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"action_event.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_event.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAC,gBAAgB,EAAc,eAAe,EAAC,MAAM,kBAAkB,CAAC;AAWpF,OAAO,KAAK,EACX,cAAc,EACd,sBAAsB,EACtB,mBAAmB,EAEnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAC,sBAAsB,EAAE,eAAe,EAAC,MAAM,yBAAyB,CAAC;AACrF,OAAO,EAAkB,KAAK,oBAAoB,EAAC,MAAM,wBAAwB,CAAC;AAelF,MAAM,MAAM,yBAAyB,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM,IAAI,CACxE,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,EACvC,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,EACvC,KAAK,EAAE,WAAW,CAAC,OAAO,CAAC,KACvB,IAAI,CAAC;AAEV;;GAEG;AACH,qBAAa,WAAW,CACvB,OAAO,SAAS,MAAM,GAAG,MAAM,EAC/B,MAAM,SAAS,gBAAgB,GAAG,gBAAgB,EAClD,KAAK,SAAS,eAAe,GAAG,eAAe;;IAK/C,QAAQ,CAAC,WAAW,EAAE,sBAAsB,CAAC;IAC7C,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAE/B,IAAI,IAAI,IAAI,oBAAoB,CAAC,OAAO,CAAC,GAAG;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,KAAK,CAAA;KAAC,CAEvE;gBAGA,WAAW,EAAE,sBAAsB,EACnC,IAAI,EAAE,eAAe,EACrB,IAAI,EAAE,oBAAoB,CAAC,OAAO,CAAC;IAOpC,MAAM,IAAI,oBAAoB,CAAC,OAAO,CAAC;IAMvC,OAAO,CAAC,QAAQ,EAAE,yBAAyB,CAAC,OAAO,CAAC,GAAG,MAAM,IAAI;IAKjE,QAAQ,CAAC,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,GAAG,IAAI;IAUvD;;OAEG;IACH,KAAK,IAAI,IAAI;IA8Cb;;OAEG;IAGG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IA0CnC;;OAEG;IACH,WAAW,IAAI,IAAI;IAkCnB;;OAEG;IACH,UAAU,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI;IAezC,WAAW,IAAI,OAAO;IAItB,eAAe,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI;IAIxC,WAAW,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI;IAQ1C,YAAY,CAAC,QAAQ,EAAE,sBAAsB,GAAG,IAAI;IAUpD,gBAAgB,CAAC,YAAY,EAAE,mBAAmB,GAAG,IAAI;CAyKzD;AAGD;;GAEG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,SAAS,MAAM,GAAG,MAAM,EAClE,aAAa,sBAAsB,EACnC,MAAM,eAAe,EACrB,OAAO,OAAO,EACd,gBAAgB,gBAAgB,KAC9B,WAAW,CAAC,OAAO,CAiBrB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,6BAA6B,GAAI,OAAO,SAAS,MAAM,GAAG,MAAM,EAC5E,MAAM,oBAAoB,CAAC,OAAO,CAAC,EACnC,aAAa,sBAAsB,KACjC,WAAW,CAAC,OAAO,CAOrB,CAAC;AAIF,eAAO,MAAM,kBAAkB,GAC9B,UAAU,OAAO,EACjB,aAAa,sBAAsB,KACjC,WAGF,CAAC"}
|
|
@@ -12,13 +12,6 @@ import { jsonrpc_error_messages, ThrownJsonrpcError } from '../http/jsonrpc_erro
|
|
|
12
12
|
import { ActionEventData } from './action_event_data.js';
|
|
13
13
|
import { validate_step_transition, validate_phase_transition, should_validate_output, is_action_complete, create_initial_data, get_initial_phase, is_request_response, is_send_request_with_parsed_input, is_notification_send_with_parsed_input, } from './action_event_helpers.js';
|
|
14
14
|
import { create_uuid } from '../uuid.js';
|
|
15
|
-
/** Formats a Zod validation error with field paths for clearer error messages. */
|
|
16
|
-
const format_zod_validation_error = (error) => error.issues
|
|
17
|
-
.map((i) => {
|
|
18
|
-
const path = i.path.length > 0 ? `${i.path.join('.')}: ` : '';
|
|
19
|
-
return `${path}${i.message}`;
|
|
20
|
-
})
|
|
21
|
-
.join(', ');
|
|
22
15
|
/**
|
|
23
16
|
* Action event that manages the lifecycle of an action through its state machine.
|
|
24
17
|
*/
|
|
@@ -87,7 +80,7 @@ export class ActionEvent {
|
|
|
87
80
|
// Handler errors (network, server, business logic) DO transition to error phases.
|
|
88
81
|
this.#fail(
|
|
89
82
|
// no need to protect this info
|
|
90
|
-
jsonrpc_error_messages.invalid_params(`failed to parse input: ${
|
|
83
|
+
jsonrpc_error_messages.invalid_params(`failed to parse input: ${z.prettifyError(parsed.error)}`, { validation_errors: parsed.error.issues }));
|
|
91
84
|
}
|
|
92
85
|
return this;
|
|
93
86
|
}
|
|
@@ -281,7 +274,7 @@ export class ActionEvent {
|
|
|
281
274
|
this.#transition_step('handled', { output: parsed.data });
|
|
282
275
|
}
|
|
283
276
|
else {
|
|
284
|
-
this.#fail(jsonrpc_error_messages.validation_error(`failed to parse output: ${
|
|
277
|
+
this.#fail(jsonrpc_error_messages.validation_error(`failed to parse output: ${z.prettifyError(parsed.error)}`, { output, validation_errors: parsed.error.issues }));
|
|
285
278
|
}
|
|
286
279
|
}
|
|
287
280
|
else {
|
|
@@ -80,6 +80,26 @@ export declare class FrontendWebsocketClient implements WebsocketConnection, Dis
|
|
|
80
80
|
last_close_reason: string | null;
|
|
81
81
|
readonly connected: boolean;
|
|
82
82
|
constructor(url: string, options?: FrontendWebsocketClientOptions);
|
|
83
|
+
/**
|
|
84
|
+
* Swap the auto-reconnect policy in place. Accepts the same shape as the
|
|
85
|
+
* constructor's `reconnect` option: `false` disables reconnect, `true` or
|
|
86
|
+
* `null`/omitted restores the defaults, or a config object customizes
|
|
87
|
+
* specific fields (missing fields fall back to defaults, not "keep
|
|
88
|
+
* current" — each call defines the whole policy atomically, same as the
|
|
89
|
+
* constructor).
|
|
90
|
+
*
|
|
91
|
+
* In-flight reconnect schedules are **monotonically shortened**: the
|
|
92
|
+
* effective total wait from arm-time never exceeds what the new policy
|
|
93
|
+
* prescribes. If the new target is already past the time already
|
|
94
|
+
* elapsed, the reconnect fires immediately (on the next tick). The wait
|
|
95
|
+
* is never extended.
|
|
96
|
+
*
|
|
97
|
+
* Turning reconnect off while a reconnect timer is pending cancels that
|
|
98
|
+
* timer and transitions status to `closed` (since the lie of
|
|
99
|
+
* `'reconnecting'` would be visible to UI indicators). Turning it back on
|
|
100
|
+
* does not synthesize a reconnect — wait for the next close.
|
|
101
|
+
*/
|
|
102
|
+
set_reconnect(reconnect?: boolean | FrontendWebsocketReconnectOptions | null): void;
|
|
83
103
|
get url(): string;
|
|
84
104
|
/**
|
|
85
105
|
* Whether the server has permanently closed the session. Once `true`, all
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"socket.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/socket.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAGpD,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,oBAAoB,CAAC;AAE5D,qDAAqD;AACrD,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,kCAAkC;AAClC,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAC5C,8DAA8D;AAC9D,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AACjD,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,QAAQ,CAAC;AAE9F,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;AACjE,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;AAExD,MAAM,WAAW,iCAAiC;IACjD,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,8BAA8B;IAC9C;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,iCAAiC,GAAG,IAAI,CAAC;IAC/D,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAED;;;;;;;;;;GAUG;AACH,qBAAa,uBAAwB,YAAW,mBAAmB,EAAE,UAAU;;IAQ9E,EAAE,EAAE,SAAS,GAAG,IAAI,CAAoB;IACxC,MAAM,EAAE,YAAY,CAAyB;IAE7C,eAAe,EAAE,MAAM,CAAiB;IACxC,uBAAuB,EAAE,MAAM,CAAiB;IAChD,2EAA2E;IAC3E,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD,yEAAyE;IACzE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,kFAAkF;IAClF,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,qEAAqE;IACrE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;
|
|
1
|
+
{"version":3,"file":"socket.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/socket.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAGpD,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,oBAAoB,CAAC;AAE5D,qDAAqD;AACrD,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,kCAAkC;AAClC,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAC5C,8DAA8D;AAC9D,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AACjD,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,QAAQ,CAAC;AAE9F,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;AACjE,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;AAExD,MAAM,WAAW,iCAAiC;IACjD,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,8BAA8B;IAC9C;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,iCAAiC,GAAG,IAAI,CAAC;IAC/D,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAED;;;;;;;;;;GAUG;AACH,qBAAa,uBAAwB,YAAW,mBAAmB,EAAE,UAAU;;IAQ9E,EAAE,EAAE,SAAS,GAAG,IAAI,CAAoB;IACxC,MAAM,EAAE,YAAY,CAAyB;IAE7C,eAAe,EAAE,MAAM,CAAiB;IACxC,uBAAuB,EAAE,MAAM,CAAiB;IAChD,2EAA2E;IAC3E,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD,yEAAyE;IACzE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,kFAAkF;IAClF,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,qEAAqE;IACrE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IASpD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAyC;gBAExD,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC;IAWrE;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,CAAC,SAAS,GAAE,OAAO,GAAG,iCAAiC,GAAG,IAAW,GAAG,IAAI;IA4CzF,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED;;;;OAIG;IACH,IAAI,OAAO,IAAI,OAAO,CAErB;IAED;;;;OAIG;IACH,OAAO,IAAI,IAAI;IA2Bf;;;;OAIG;IACH,UAAU,CAAC,IAAI,GAAE,MAA2B,GAAG,IAAI;IAQnD,sGAAsG;IACtG,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;IAIxB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAW3B,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,MAAM,IAAI;IAK9D,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,IAAI;CAiH1D"}
|
|
@@ -54,6 +54,7 @@ export class FrontendWebsocketClient {
|
|
|
54
54
|
/** Reason string from the most recent close event (may be empty). */
|
|
55
55
|
last_close_reason = $state.raw(null);
|
|
56
56
|
#reconnect_timeout = null;
|
|
57
|
+
#reconnect_scheduled_at = null;
|
|
57
58
|
#revoked = $state.raw(false);
|
|
58
59
|
#message_handlers = new Set();
|
|
59
60
|
#error_handlers = new Set();
|
|
@@ -68,6 +69,61 @@ export class FrontendWebsocketClient {
|
|
|
68
69
|
this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
|
|
69
70
|
this.#log = options.log ?? null;
|
|
70
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Swap the auto-reconnect policy in place. Accepts the same shape as the
|
|
74
|
+
* constructor's `reconnect` option: `false` disables reconnect, `true` or
|
|
75
|
+
* `null`/omitted restores the defaults, or a config object customizes
|
|
76
|
+
* specific fields (missing fields fall back to defaults, not "keep
|
|
77
|
+
* current" — each call defines the whole policy atomically, same as the
|
|
78
|
+
* constructor).
|
|
79
|
+
*
|
|
80
|
+
* In-flight reconnect schedules are **monotonically shortened**: the
|
|
81
|
+
* effective total wait from arm-time never exceeds what the new policy
|
|
82
|
+
* prescribes. If the new target is already past the time already
|
|
83
|
+
* elapsed, the reconnect fires immediately (on the next tick). The wait
|
|
84
|
+
* is never extended.
|
|
85
|
+
*
|
|
86
|
+
* Turning reconnect off while a reconnect timer is pending cancels that
|
|
87
|
+
* timer and transitions status to `closed` (since the lie of
|
|
88
|
+
* `'reconnecting'` would be visible to UI indicators). Turning it back on
|
|
89
|
+
* does not synthesize a reconnect — wait for the next close.
|
|
90
|
+
*/
|
|
91
|
+
set_reconnect(reconnect = null) {
|
|
92
|
+
const next_auto = reconnect !== false;
|
|
93
|
+
const config = typeof reconnect === 'object' && reconnect !== null ? reconnect : {};
|
|
94
|
+
this.#auto_reconnect = next_auto;
|
|
95
|
+
this.#reconnect_delay = config.delay ?? DEFAULT_RECONNECT_DELAY;
|
|
96
|
+
this.#reconnect_delay_max = config.delay_max ?? DEFAULT_RECONNECT_DELAY_MAX;
|
|
97
|
+
this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
|
|
98
|
+
if (this.#reconnect_timeout === null)
|
|
99
|
+
return;
|
|
100
|
+
if (!next_auto) {
|
|
101
|
+
this.#cancel_reconnect();
|
|
102
|
+
this.status = 'closed';
|
|
103
|
+
this.reconnect_count = 0;
|
|
104
|
+
this.current_reconnect_delay = 0;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Auto-reconnect still on: monotonically shorten the pending wait if
|
|
108
|
+
// the new policy would produce a shorter total wait from arm-time.
|
|
109
|
+
// Never extends.
|
|
110
|
+
const scheduled_at = this.#reconnect_scheduled_at ?? Date.now();
|
|
111
|
+
const elapsed = Math.max(0, Date.now() - scheduled_at);
|
|
112
|
+
const remaining = Math.max(0, this.current_reconnect_delay - elapsed);
|
|
113
|
+
const new_target = Math.round(Math.min(this.#reconnect_delay_max, this.#reconnect_delay * this.#backoff_factor ** Math.max(0, this.reconnect_count - 1)));
|
|
114
|
+
const new_remaining = Math.max(0, new_target - elapsed);
|
|
115
|
+
if (new_remaining >= remaining)
|
|
116
|
+
return;
|
|
117
|
+
clearTimeout(this.#reconnect_timeout);
|
|
118
|
+
this.current_reconnect_delay = new_target;
|
|
119
|
+
// Keep #reconnect_scheduled_at at the original arm time so subsequent
|
|
120
|
+
// set_reconnect calls compute elapsed against a stable origin.
|
|
121
|
+
this.#reconnect_timeout = setTimeout(() => {
|
|
122
|
+
this.#reconnect_timeout = null;
|
|
123
|
+
this.#reconnect_scheduled_at = null;
|
|
124
|
+
this.connect();
|
|
125
|
+
}, new_remaining);
|
|
126
|
+
}
|
|
71
127
|
get url() {
|
|
72
128
|
return this.#url;
|
|
73
129
|
}
|
|
@@ -179,8 +235,10 @@ export class FrontendWebsocketClient {
|
|
|
179
235
|
this.reconnect_count++;
|
|
180
236
|
this.current_reconnect_delay = Math.round(Math.min(this.#reconnect_delay_max, this.#reconnect_delay * this.#backoff_factor ** (this.reconnect_count - 1)));
|
|
181
237
|
this.status = 'reconnecting';
|
|
238
|
+
this.#reconnect_scheduled_at = Date.now();
|
|
182
239
|
this.#reconnect_timeout = setTimeout(() => {
|
|
183
240
|
this.#reconnect_timeout = null;
|
|
241
|
+
this.#reconnect_scheduled_at = null;
|
|
184
242
|
this.connect();
|
|
185
243
|
}, this.current_reconnect_delay);
|
|
186
244
|
}
|
|
@@ -189,6 +247,7 @@ export class FrontendWebsocketClient {
|
|
|
189
247
|
clearTimeout(this.#reconnect_timeout);
|
|
190
248
|
this.#reconnect_timeout = null;
|
|
191
249
|
}
|
|
250
|
+
this.#reconnect_scheduled_at = null;
|
|
192
251
|
}
|
|
193
252
|
#handle_open = (_event) => {
|
|
194
253
|
this.status = 'connected';
|
package/dist/env/dotenv.d.ts
CHANGED
|
@@ -10,6 +10,19 @@ import type { FsReadDeps } from '../runtime/deps.js';
|
|
|
10
10
|
/**
|
|
11
11
|
* Parse a dotenv-format string into a record.
|
|
12
12
|
*
|
|
13
|
+
* Values wrapped in `"..."` have `\\` → `\`, `\"` → `"`, `\n` → newline,
|
|
14
|
+
* and `\r` → carriage-return decoded (symmetric with the writer in
|
|
15
|
+
* `update_env_variable`). Values wrapped in `'...'` are taken literally —
|
|
16
|
+
* no escape processing. Unquoted values are unchanged.
|
|
17
|
+
*
|
|
18
|
+
* Inline comments are stripped after a closing quote (e.g. `KEY="v" # c` → `v`)
|
|
19
|
+
* and after whitespace on unquoted values (e.g. `KEY=v # c` → `v`). Unquoted
|
|
20
|
+
* values keep `#` literal when no whitespace precedes it so URL fragments
|
|
21
|
+
* like `KEY=https://x.com#frag` round-trip unchanged.
|
|
22
|
+
*
|
|
23
|
+
* Trailing whitespace on unquoted values is lost (the raw value is trimmed);
|
|
24
|
+
* wrap the value in `"..."` or `'...'` to preserve surrounding spacing.
|
|
25
|
+
*
|
|
13
26
|
* @param content - dotenv file content
|
|
14
27
|
* @returns parsed key-value pairs
|
|
15
28
|
*/
|
|
@@ -17,6 +30,10 @@ export declare const parse_dotenv: (content: string) => Record<string, string>;
|
|
|
17
30
|
/**
|
|
18
31
|
* Load and parse an env file.
|
|
19
32
|
*
|
|
33
|
+
* Returns null only when the file does not exist. Other read errors
|
|
34
|
+
* (permission denied, I/O failure, etc.) are re-thrown so callers can
|
|
35
|
+
* distinguish "no file" from "couldn't read".
|
|
36
|
+
*
|
|
20
37
|
* @param runtime - runtime with `read_text_file` capability
|
|
21
38
|
* @param path - path to env file
|
|
22
39
|
* @returns parsed env record, or null if file doesn't exist
|
package/dist/env/dotenv.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dotenv.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/env/dotenv.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,oBAAoB,CAAC;AAEnD
|
|
1
|
+
{"version":3,"file":"dotenv.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/env/dotenv.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,oBAAoB,CAAC;AAEnD;;;;;;;;;;;;;;;;;;GAkBG;AAGH,eAAO,MAAM,YAAY,GAAI,SAAS,MAAM,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAkCnE,CAAC;AAiEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,GACzB,SAAS,IAAI,CAAC,UAAU,EAAE,gBAAgB,CAAC,EAC3C,MAAM,MAAM,KACV,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CASvC,CAAC"}
|
package/dist/env/dotenv.js
CHANGED
|
@@ -9,14 +9,28 @@
|
|
|
9
9
|
/**
|
|
10
10
|
* Parse a dotenv-format string into a record.
|
|
11
11
|
*
|
|
12
|
+
* Values wrapped in `"..."` have `\\` → `\`, `\"` → `"`, `\n` → newline,
|
|
13
|
+
* and `\r` → carriage-return decoded (symmetric with the writer in
|
|
14
|
+
* `update_env_variable`). Values wrapped in `'...'` are taken literally —
|
|
15
|
+
* no escape processing. Unquoted values are unchanged.
|
|
16
|
+
*
|
|
17
|
+
* Inline comments are stripped after a closing quote (e.g. `KEY="v" # c` → `v`)
|
|
18
|
+
* and after whitespace on unquoted values (e.g. `KEY=v # c` → `v`). Unquoted
|
|
19
|
+
* values keep `#` literal when no whitespace precedes it so URL fragments
|
|
20
|
+
* like `KEY=https://x.com#frag` round-trip unchanged.
|
|
21
|
+
*
|
|
22
|
+
* Trailing whitespace on unquoted values is lost (the raw value is trimmed);
|
|
23
|
+
* wrap the value in `"..."` or `'...'` to preserve surrounding spacing.
|
|
24
|
+
*
|
|
12
25
|
* @param content - dotenv file content
|
|
13
26
|
* @returns parsed key-value pairs
|
|
14
27
|
*/
|
|
28
|
+
// Line tokenization (trim, skip empties/comments, split on first `=`) is
|
|
29
|
+
// mirrored in `update_env_variable.ts`'s `find_last_key_line_index`.
|
|
15
30
|
export const parse_dotenv = (content) => {
|
|
16
31
|
const result = {};
|
|
17
32
|
for (const line of content.split('\n')) {
|
|
18
33
|
const trimmed = line.trim();
|
|
19
|
-
// skip empty lines and comments
|
|
20
34
|
if (!trimmed || trimmed.startsWith('#'))
|
|
21
35
|
continue;
|
|
22
36
|
const eq_index = trimmed.indexOf('=');
|
|
@@ -24,19 +38,101 @@ export const parse_dotenv = (content) => {
|
|
|
24
38
|
continue;
|
|
25
39
|
const key = trimmed.slice(0, eq_index).trim();
|
|
26
40
|
let value = trimmed.slice(eq_index + 1).trim();
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
if (value.startsWith('"')) {
|
|
42
|
+
const close = find_closing_double_quote(value);
|
|
43
|
+
if (close !== -1 && is_comment_or_empty_after(value, close)) {
|
|
44
|
+
value = unescape_double_quoted(value.slice(1, close));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (value.startsWith("'")) {
|
|
48
|
+
const close = value.indexOf("'", 1);
|
|
49
|
+
if (close !== -1 && is_comment_or_empty_after(value, close)) {
|
|
50
|
+
value = value.slice(1, close);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else if (value.startsWith('#')) {
|
|
54
|
+
// Leading `#` on an unquoted value means the value is empty and the
|
|
55
|
+
// rest is a comment (`KEY=#c` or `KEY= # c` → `''`).
|
|
56
|
+
value = '';
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// Unquoted: strip trailing `\s+#...` so `KEY=v # c` → `v` while
|
|
60
|
+
// `KEY=https://x.com#frag` (no whitespace before `#`) stays literal.
|
|
61
|
+
const comment_idx = value.search(/\s+#/);
|
|
62
|
+
if (comment_idx !== -1)
|
|
63
|
+
value = value.slice(0, comment_idx).trimEnd();
|
|
32
64
|
}
|
|
33
65
|
result[key] = value;
|
|
34
66
|
}
|
|
35
67
|
return result;
|
|
36
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* Find the index of the unescaped closing `"` in a `"..."`-wrapped value.
|
|
71
|
+
* Returns -1 if no closing quote is found. Caller guarantees `value[0] === '"'`.
|
|
72
|
+
*/
|
|
73
|
+
const find_closing_double_quote = (value) => {
|
|
74
|
+
let i = 1;
|
|
75
|
+
while (i < value.length) {
|
|
76
|
+
const ch = value[i];
|
|
77
|
+
if (ch === '\\' && i + 1 < value.length) {
|
|
78
|
+
i += 2;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (ch === '"')
|
|
82
|
+
return i;
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
return -1;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Returns true if everything after `pos` (the closing quote) is whitespace
|
|
89
|
+
* or a `# ...` inline comment. Used to decide whether the line is a clean
|
|
90
|
+
* `KEY="value" [# comment]` assignment vs. something we should leave raw.
|
|
91
|
+
*/
|
|
92
|
+
const is_comment_or_empty_after = (value, pos) => /^\s*(#.*)?$/.test(value.slice(pos + 1));
|
|
93
|
+
/**
|
|
94
|
+
* Single-pass unescape for the inside of a `"..."` dotenv value.
|
|
95
|
+
*
|
|
96
|
+
* Recognizes `\\` → `\`, `\"` → `"`, `\n` → newline, `\r` → CR. Any other
|
|
97
|
+
* backslash is emitted literally. A chained `.replace(/\\\\/g, '\\').replace(...)`
|
|
98
|
+
* would mishandle real-backslash followed by escaped-quote (`\\"` on disk
|
|
99
|
+
* = `[\][\]["]` would collapse to `"` instead of `\"`).
|
|
100
|
+
*/
|
|
101
|
+
const unescape_double_quoted = (s) => {
|
|
102
|
+
let result = '';
|
|
103
|
+
let i = 0;
|
|
104
|
+
while (i < s.length) {
|
|
105
|
+
const ch = s[i];
|
|
106
|
+
if (ch === '\\' && i + 1 < s.length) {
|
|
107
|
+
const next = s[i + 1];
|
|
108
|
+
if (next === '\\' || next === '"') {
|
|
109
|
+
result += next;
|
|
110
|
+
i += 2;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (next === 'n') {
|
|
114
|
+
result += '\n';
|
|
115
|
+
i += 2;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (next === 'r') {
|
|
119
|
+
result += '\r';
|
|
120
|
+
i += 2;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
result += ch;
|
|
125
|
+
i++;
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
};
|
|
37
129
|
/**
|
|
38
130
|
* Load and parse an env file.
|
|
39
131
|
*
|
|
132
|
+
* Returns null only when the file does not exist. Other read errors
|
|
133
|
+
* (permission denied, I/O failure, etc.) are re-thrown so callers can
|
|
134
|
+
* distinguish "no file" from "couldn't read".
|
|
135
|
+
*
|
|
40
136
|
* @param runtime - runtime with `read_text_file` capability
|
|
41
137
|
* @param path - path to env file
|
|
42
138
|
* @returns parsed env record, or null if file doesn't exist
|
|
@@ -46,7 +142,10 @@ export const load_env_file = async (runtime, path) => {
|
|
|
46
142
|
const content = await runtime.read_text_file(path);
|
|
47
143
|
return parse_dotenv(content);
|
|
48
144
|
}
|
|
49
|
-
catch {
|
|
50
|
-
|
|
145
|
+
catch (error) {
|
|
146
|
+
// Node (`ENOENT`) and Deno (`Deno.errors.NotFound`) — handle both.
|
|
147
|
+
if (error?.code === 'ENOENT' || error?.name === 'NotFound')
|
|
148
|
+
return null;
|
|
149
|
+
throw error;
|
|
51
150
|
}
|
|
52
151
|
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write updates to `.env` files while preserving formatting.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Options for updating environment variables in a .env file.
|
|
8
|
+
*/
|
|
9
|
+
export interface UpdateEnvVariableOptions {
|
|
10
|
+
/** Path to the .env file. */
|
|
11
|
+
env_file_path: string;
|
|
12
|
+
/** Function to read file contents (defaults to `node:fs/promises` `readFile`). */
|
|
13
|
+
read_file?: (path: string, encoding: string) => Promise<string>;
|
|
14
|
+
/** Function to write file contents (defaults to `node:fs/promises` `writeFile`). */
|
|
15
|
+
write_file?: (path: string, content: string, encoding: string) => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Updates or adds an environment variable in the .env file.
|
|
19
|
+
* Preserves existing formatting, comments, and other variables.
|
|
20
|
+
*
|
|
21
|
+
* Behavior:
|
|
22
|
+
* - **Duplicate keys**: updates the LAST occurrence (matches dotenv behavior)
|
|
23
|
+
* - **Inline comments**: preserved after the value (e.g., `KEY=value # comment`)
|
|
24
|
+
* - **Quote style**: preserved from original (quoted/unquoted)
|
|
25
|
+
*
|
|
26
|
+
* @warning Not atomic; not safe for concurrent writers. Reads the file, mutates
|
|
27
|
+
* in memory, then writes it back — a crash or concurrent write can corrupt
|
|
28
|
+
* the file. Acceptable for single-user, infrequent edits; revisit if a
|
|
29
|
+
* concurrent-writer consumer emerges.
|
|
30
|
+
*
|
|
31
|
+
* @param key - the environment variable name (e.g., `'SOME_CONFIGURATION_KEY'`)
|
|
32
|
+
* @param value - the new value for the environment variable
|
|
33
|
+
* @param options - file path and optional read/write overrides
|
|
34
|
+
*/
|
|
35
|
+
export declare const update_env_variable: (key: string, value: string, options: UpdateEnvVariableOptions) => Promise<void>;
|
|
36
|
+
//# sourceMappingURL=update_env_variable.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"update_env_variable.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/env/update_env_variable.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACxC,6BAA6B;IAC7B,aAAa,EAAE,MAAM,CAAC;IACtB,kFAAkF;IAClF,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAChE,oFAAoF;IACpF,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAChF;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,MAAM,EACX,OAAO,MAAM,EACb,SAAS,wBAAwB,KAC/B,OAAO,CAAC,IAAI,CAmDd,CAAC"}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write updates to `.env` files while preserving formatting.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
/**
|
|
9
|
+
* Updates or adds an environment variable in the .env file.
|
|
10
|
+
* Preserves existing formatting, comments, and other variables.
|
|
11
|
+
*
|
|
12
|
+
* Behavior:
|
|
13
|
+
* - **Duplicate keys**: updates the LAST occurrence (matches dotenv behavior)
|
|
14
|
+
* - **Inline comments**: preserved after the value (e.g., `KEY=value # comment`)
|
|
15
|
+
* - **Quote style**: preserved from original (quoted/unquoted)
|
|
16
|
+
*
|
|
17
|
+
* @warning Not atomic; not safe for concurrent writers. Reads the file, mutates
|
|
18
|
+
* in memory, then writes it back — a crash or concurrent write can corrupt
|
|
19
|
+
* the file. Acceptable for single-user, infrequent edits; revisit if a
|
|
20
|
+
* concurrent-writer consumer emerges.
|
|
21
|
+
*
|
|
22
|
+
* @param key - the environment variable name (e.g., `'SOME_CONFIGURATION_KEY'`)
|
|
23
|
+
* @param value - the new value for the environment variable
|
|
24
|
+
* @param options - file path and optional read/write overrides
|
|
25
|
+
*/
|
|
26
|
+
export const update_env_variable = async (key, value, options) => {
|
|
27
|
+
const { env_file_path, read_file = readFile, write_file = writeFile } = options;
|
|
28
|
+
const file_path = resolve(env_file_path);
|
|
29
|
+
let content = '';
|
|
30
|
+
try {
|
|
31
|
+
content = await read_file(file_path, 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error?.code !== 'ENOENT') {
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// TODO CRLF: split on `/\r?\n/` + remember per-line delimiter (pinned by edge_cases.test.ts)
|
|
39
|
+
// Preserve trailing-newline state: `split('\n')` on `'a\n'` yields `['a', '']`
|
|
40
|
+
// — drop that trailing empty so appends don't insert a blank line. Re-add
|
|
41
|
+
// the trailing `\n` at the end if the original had one.
|
|
42
|
+
const has_trailing_newline = content.endsWith('\n');
|
|
43
|
+
const lines = content.split('\n');
|
|
44
|
+
if (has_trailing_newline)
|
|
45
|
+
lines.pop();
|
|
46
|
+
// Find the LAST occurrence of the key (matches dotenv "last wins" behavior)
|
|
47
|
+
const last_match_idx = find_last_key_line_index(lines, key);
|
|
48
|
+
const updated_lines = lines.map((line, idx) => {
|
|
49
|
+
if (idx === last_match_idx) {
|
|
50
|
+
const equals_pos = line.indexOf('=');
|
|
51
|
+
const value_part = line.substring(equals_pos + 1);
|
|
52
|
+
const inline_comment = extract_inline_comment(value_part);
|
|
53
|
+
const trimmed_value = value_part.trim();
|
|
54
|
+
const has_quotes = is_quoted_value(trimmed_value);
|
|
55
|
+
return has_quotes
|
|
56
|
+
? `${key}=${quote_value(value)}${inline_comment}`
|
|
57
|
+
: `${key}=${value}${inline_comment}`;
|
|
58
|
+
}
|
|
59
|
+
return line;
|
|
60
|
+
});
|
|
61
|
+
if (last_match_idx === -1) {
|
|
62
|
+
if (content === '') {
|
|
63
|
+
await write_file(file_path, `${key}=${quote_value(value)}\n`, 'utf-8');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
updated_lines.push(`${key}=${quote_value(value)}`);
|
|
67
|
+
}
|
|
68
|
+
const updated_content = updated_lines.join('\n') + (has_trailing_newline ? '\n' : '');
|
|
69
|
+
await write_file(file_path, updated_content, 'utf-8');
|
|
70
|
+
};
|
|
71
|
+
// Keep this tokenization aligned with `parse_dotenv` in `./dotenv.ts`:
|
|
72
|
+
// trim, skip empties/comments, split on the first `=`.
|
|
73
|
+
const find_last_key_line_index = (lines, key) => {
|
|
74
|
+
if (!key)
|
|
75
|
+
return -1;
|
|
76
|
+
let last_match_idx = -1;
|
|
77
|
+
lines.forEach((line, idx) => {
|
|
78
|
+
const trimmed = line.trim();
|
|
79
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
80
|
+
return;
|
|
81
|
+
const eq_index = trimmed.indexOf('=');
|
|
82
|
+
if (eq_index === -1)
|
|
83
|
+
return;
|
|
84
|
+
if (trimmed.slice(0, eq_index).trim() === key)
|
|
85
|
+
last_match_idx = idx;
|
|
86
|
+
});
|
|
87
|
+
return last_match_idx;
|
|
88
|
+
};
|
|
89
|
+
const extract_inline_comment = (value_part) => {
|
|
90
|
+
const trimmed_value = value_part.trim();
|
|
91
|
+
if (is_quoted_value(trimmed_value)) {
|
|
92
|
+
const quote_char = trimmed_value[0];
|
|
93
|
+
let closing_quote_idx = trimmed_value.indexOf(quote_char, 1);
|
|
94
|
+
while (closing_quote_idx > 0 && is_quote_escaped(trimmed_value, closing_quote_idx)) {
|
|
95
|
+
closing_quote_idx = trimmed_value.indexOf(quote_char, closing_quote_idx + 1);
|
|
96
|
+
}
|
|
97
|
+
if (closing_quote_idx !== -1) {
|
|
98
|
+
const after_quote = trimmed_value.substring(closing_quote_idx + 1);
|
|
99
|
+
const comment_match = /(\s*#.*)/.exec(after_quote);
|
|
100
|
+
const captured_comment = comment_match?.[1];
|
|
101
|
+
if (captured_comment) {
|
|
102
|
+
return captured_comment;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// Leading `#` (e.g. `KEY=#c`) — whole trailing text is a comment. Emit
|
|
108
|
+
// with a space separator so it can't merge with the new value and the
|
|
109
|
+
// parser can round-trip the new value cleanly.
|
|
110
|
+
const trimmed_vp = value_part.trimStart();
|
|
111
|
+
if (trimmed_vp.startsWith('#'))
|
|
112
|
+
return ' ' + trimmed_vp;
|
|
113
|
+
// Require whitespace before `#` — symmetric with `parse_dotenv`, which
|
|
114
|
+
// strips `\s+#...` from unquoted values. URL fragments (no whitespace
|
|
115
|
+
// before `#`) are literal on both sides.
|
|
116
|
+
const comment_match = /(\s+#.*)/.exec(value_part);
|
|
117
|
+
const captured_comment = comment_match?.[1];
|
|
118
|
+
if (captured_comment) {
|
|
119
|
+
return captured_comment;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return '';
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Checks if a quote character at a specific position is escaped by counting
|
|
126
|
+
* consecutive backslashes before it. An odd count means the quote is escaped.
|
|
127
|
+
*/
|
|
128
|
+
const is_quote_escaped = (str, quote_pos) => {
|
|
129
|
+
let backslash_count = 0;
|
|
130
|
+
let pos = quote_pos - 1;
|
|
131
|
+
while (pos >= 0 && str[pos] === '\\') {
|
|
132
|
+
backslash_count++;
|
|
133
|
+
pos--;
|
|
134
|
+
}
|
|
135
|
+
return backslash_count % 2 === 1;
|
|
136
|
+
};
|
|
137
|
+
const QUOTE_CHARS = ['"', "'"];
|
|
138
|
+
const is_quoted_value = (value) => QUOTE_CHARS.some((char) => value.startsWith(char));
|
|
139
|
+
/**
|
|
140
|
+
* Wraps a value for safe insertion into a double- or single-quoted dotenv line.
|
|
141
|
+
*
|
|
142
|
+
* - Uses `'...'` when the value contains `"` but no `'`, no `\n`, and no `\r`
|
|
143
|
+
* (single-quoted dotenv values are taken literally — no escape processing,
|
|
144
|
+
* and a literal newline would break the line into two).
|
|
145
|
+
* - Otherwise uses `"..."` with `\` → `\\`, `"` → `\"`, newline → `\n`, and
|
|
146
|
+
* CR → `\r` so the line stays a single parseable assignment and round-trips
|
|
147
|
+
* through `parse_dotenv` losslessly.
|
|
148
|
+
*/
|
|
149
|
+
const quote_value = (value) => {
|
|
150
|
+
if (value.includes('"') && !value.includes("'") && !has_newline_chars(value)) {
|
|
151
|
+
return `'${value}'`;
|
|
152
|
+
}
|
|
153
|
+
return `"${escape_for_double_quoted(value)}"`;
|
|
154
|
+
};
|
|
155
|
+
const has_newline_chars = (value) => value.includes('\n') || value.includes('\r');
|
|
156
|
+
/**
|
|
157
|
+
* Order matters: backslashes must be escaped first so the introduced
|
|
158
|
+
* backslashes from later replacements aren't re-escaped.
|
|
159
|
+
*/
|
|
160
|
+
const escape_for_double_quoted = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory file system mock for tests that use dependency-injected
|
|
3
|
+
* `read_file` and `write_file` callbacks. Avoids module-level mocking.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
export interface MockFs {
|
|
8
|
+
read_file: (path: string, encoding: string) => Promise<string>;
|
|
9
|
+
write_file: (path: string, content: string, encoding: string) => Promise<void>;
|
|
10
|
+
get_file: (path: string) => string | undefined;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Creates an in-memory file system for tests.
|
|
14
|
+
*
|
|
15
|
+
* `read_file` throws an `ENOENT`-tagged error for missing paths so callers
|
|
16
|
+
* can exercise the same "file doesn't exist" code path as `node:fs`.
|
|
17
|
+
*
|
|
18
|
+
* @param initial_files - starting contents keyed by absolute path
|
|
19
|
+
*/
|
|
20
|
+
export declare const create_mock_fs: (initial_files?: Record<string, string>) => MockFs;
|
|
21
|
+
//# sourceMappingURL=mock_fs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mock_fs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/mock_fs.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,WAAW,MAAM;IACtB,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/D,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/E,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CAC/C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,gBAAe,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,KAAG,MAuB3E,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory file system mock for tests that use dependency-injected
|
|
3
|
+
* `read_file` and `write_file` callbacks. Avoids module-level mocking.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Creates an in-memory file system for tests.
|
|
9
|
+
*
|
|
10
|
+
* `read_file` throws an `ENOENT`-tagged error for missing paths so callers
|
|
11
|
+
* can exercise the same "file doesn't exist" code path as `node:fs`.
|
|
12
|
+
*
|
|
13
|
+
* @param initial_files - starting contents keyed by absolute path
|
|
14
|
+
*/
|
|
15
|
+
export const create_mock_fs = (initial_files = {}) => {
|
|
16
|
+
const files = { ...initial_files };
|
|
17
|
+
return {
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
19
|
+
read_file: async (path, _encoding) => {
|
|
20
|
+
if (!(path in files)) {
|
|
21
|
+
const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
22
|
+
error.code = 'ENOENT';
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
const file_content = files[path];
|
|
26
|
+
if (file_content === undefined) {
|
|
27
|
+
throw new Error(`File at ${path} exists in record but has undefined content`);
|
|
28
|
+
}
|
|
29
|
+
return file_content;
|
|
30
|
+
},
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
32
|
+
write_file: async (path, content, _encoding) => {
|
|
33
|
+
files[path] = content;
|
|
34
|
+
},
|
|
35
|
+
get_file: (path) => files[path],
|
|
36
|
+
};
|
|
37
|
+
};
|