mobile-automator 0.23.8

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.
Files changed (61) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +167 -0
  3. package/bin/mauto-session-daemon.js +101 -0
  4. package/bin/mauto.js +13 -0
  5. package/package.json +49 -0
  6. package/src/assertion/evaluator.js +285 -0
  7. package/src/cli.js +1722 -0
  8. package/src/config/coerce.js +97 -0
  9. package/src/config/manager.js +75 -0
  10. package/src/config/schema.js +124 -0
  11. package/src/device/action-catalog.js +99 -0
  12. package/src/device/bridge.js +139 -0
  13. package/src/device/connection.js +65 -0
  14. package/src/device/device-model.js +59 -0
  15. package/src/device/device-resolver.js +34 -0
  16. package/src/device/element-model.js +82 -0
  17. package/src/device/mobile-mcp-client.js +163 -0
  18. package/src/device/resolve-connection.js +156 -0
  19. package/src/device/selection.js +80 -0
  20. package/src/device/semantic-press/index.js +38 -0
  21. package/src/device/semantic-press/labels.js +39 -0
  22. package/src/device/semantic-press/resolver.js +100 -0
  23. package/src/device/session-client.js +173 -0
  24. package/src/device/session-daemon.js +526 -0
  25. package/src/device/session-paths.js +80 -0
  26. package/src/device/session-protocol.js +60 -0
  27. package/src/device/session-spawn.js +67 -0
  28. package/src/device/tool-args.js +14 -0
  29. package/src/guide/content/execute.agnostic.md +207 -0
  30. package/src/guide/content/execute.aware.md +281 -0
  31. package/src/guide/content/execute.invariants.md +4 -0
  32. package/src/guide/content/generate.agnostic.md +236 -0
  33. package/src/guide/content/generate.aware.md +327 -0
  34. package/src/guide/content/generate.invariants.md +5 -0
  35. package/src/guide/content/setup.agnostic.md +63 -0
  36. package/src/guide/content/setup.aware.md +91 -0
  37. package/src/guide/content/setup.invariants.md +3 -0
  38. package/src/guide/emitter.js +137 -0
  39. package/src/guide/placeholders.js +132 -0
  40. package/src/init/adapters.js +162 -0
  41. package/src/init/skill-meta.js +31 -0
  42. package/src/init/skill-renderer.js +60 -0
  43. package/src/mcp/prompts.js +55 -0
  44. package/src/mcp/server.js +55 -0
  45. package/src/memory/entries.js +61 -0
  46. package/src/memory/history.js +86 -0
  47. package/src/memory/lock.js +5 -0
  48. package/src/memory/paths.js +47 -0
  49. package/src/memory/store.js +230 -0
  50. package/src/output/envelope.js +106 -0
  51. package/src/result/capability-catalog.js +144 -0
  52. package/src/result/flags.js +52 -0
  53. package/src/result/store.js +273 -0
  54. package/src/scenario/validator.js +30 -0
  55. package/src/schemas/config_schema.json +68 -0
  56. package/src/schemas/format-error.js +19 -0
  57. package/src/schemas/result_schema.json +211 -0
  58. package/src/schemas/scenario_schema.json +587 -0
  59. package/src/setup/scaffold.js +53 -0
  60. package/src/util/atomic.js +41 -0
  61. package/src/util/lock.js +85 -0
@@ -0,0 +1,587 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Mobile QA Test Scenario",
4
+ "description": "Schema for mobile QA test scenario files generated by mauto generate. Supports dynamic waits, optional steps, conditional branching, nested sub-flows, variable capture/reuse, retry policies, and pattern-based assertions.",
5
+ "type": "object",
6
+ "required": [
7
+ "$schema_version",
8
+ "scenario_id",
9
+ "name",
10
+ "description",
11
+ "platform",
12
+ "app_package",
13
+ "metadata",
14
+ "steps",
15
+ "assertions"
16
+ ],
17
+ "properties": {
18
+ "$schema_version": {
19
+ "type": "string",
20
+ "enum": ["2.0", "2.1"],
21
+ "default": "2.0",
22
+ "description": "Schema version identifier. '2.0' or '2.1'."
23
+ },
24
+ "scenario_id": {
25
+ "type": "string",
26
+ "pattern": "^[a-z][a-z0-9_]*$",
27
+ "description": "Unique identifier in snake_case (e.g., 'loyalty_voucher_redemption')"
28
+ },
29
+ "name": {
30
+ "type": "string",
31
+ "description": "Human-readable name of the test scenario"
32
+ },
33
+ "description": {
34
+ "type": "string",
35
+ "description": "What this scenario tests and what business value it validates"
36
+ },
37
+ "platform": {
38
+ "type": "string",
39
+ "enum": [
40
+ "android",
41
+ "ios",
42
+ "cross-platform"
43
+ ]
44
+ },
45
+ "app_package": {
46
+ "type": "string",
47
+ "description": "Bundle ID or package name of the app under test"
48
+ },
49
+ "metadata": {
50
+ "type": "object",
51
+ "required": [
52
+ "app_version",
53
+ "environment"
54
+ ],
55
+ "description": "Design-time metadata only. Execution-time data (device_model, api_level, timestamp) belongs in the result schema.",
56
+ "properties": {
57
+ "app_version": {
58
+ "type": "string",
59
+ "description": "Build variant or version targeted by this scenario (e.g., 'staging-latest', '2.4.1-debug')"
60
+ },
61
+ "environment": {
62
+ "type": "string",
63
+ "description": "Target environment (e.g., 'production', 'staging', 'development')"
64
+ },
65
+ "target_version": {
66
+ "type": "string",
67
+ "description": "Optional: target app major version (e.g., 'v4.x') for scenario compatibility tracking"
68
+ }
69
+ },
70
+ "additionalProperties": false
71
+ },
72
+ "tags": {
73
+ "type": "array",
74
+ "items": {
75
+ "type": "string",
76
+ "pattern": "^[a-z0-9][a-z0-9-]*$",
77
+ "maxLength": 20
78
+ },
79
+ "maxItems": 10,
80
+ "uniqueItems": true,
81
+ "description": "Categorization tags for filtering. Format: lowercase alphanumeric + hyphens only (e.g., 'smoke', 'regression', 'p0', 'auth'). Max 10 tags per scenario, max 20 characters per tag."
82
+ },
83
+ "variables": {
84
+ "type": "object",
85
+ "description": "Declare variables that will be captured during test execution and used in later steps or assertions.",
86
+ "additionalProperties": {
87
+ "type": "object",
88
+ "required": [
89
+ "type",
90
+ "description"
91
+ ],
92
+ "properties": {
93
+ "type": {
94
+ "type": "string",
95
+ "enum": [
96
+ "string",
97
+ "integer",
98
+ "number",
99
+ "boolean"
100
+ ],
101
+ "description": "Data type of the captured variable"
102
+ },
103
+ "description": {
104
+ "type": "string",
105
+ "description": "Human-readable description of what this variable represents"
106
+ }
107
+ }
108
+ }
109
+ },
110
+ "preconditions": {
111
+ "type": "object",
112
+ "description": "Required state and setup actions before test execution begins.",
113
+ "properties": {
114
+ "app_state": {
115
+ "type": "string",
116
+ "enum": [
117
+ "fresh_install",
118
+ "logged_in",
119
+ "logged_out",
120
+ "any"
121
+ ],
122
+ "description": "Required app state before the test begins"
123
+ },
124
+ "device_actions": {
125
+ "type": "array",
126
+ "description": "Automated setup actions to perform on the device before the scenario starts",
127
+ "items": {
128
+ "type": "object",
129
+ "required": [
130
+ "action"
131
+ ],
132
+ "properties": {
133
+ "action": {
134
+ "type": "string",
135
+ "enum": [
136
+ "clear_app_data",
137
+ "uninstall_app",
138
+ "install_app",
139
+ "enable_wifi",
140
+ "disable_wifi",
141
+ "set_orientation"
142
+ ],
143
+ "description": "The setup action to perform"
144
+ },
145
+ "target_package": {
146
+ "type": "string",
147
+ "description": "App package to target (for clear_app_data and uninstall_app)"
148
+ },
149
+ "value": {
150
+ "type": "string",
151
+ "description": "Value parameter for the action (e.g., orientation value)"
152
+ },
153
+ "description": {
154
+ "type": "string",
155
+ "description": "Human-readable description of why this action is needed"
156
+ }
157
+ }
158
+ }
159
+ },
160
+ "device_properties": {
161
+ "type": "object",
162
+ "description": "Required device properties at test start",
163
+ "properties": {
164
+ "network": {
165
+ "type": "string",
166
+ "enum": [
167
+ "connected",
168
+ "wifi",
169
+ "cellular",
170
+ "disconnected"
171
+ ]
172
+ },
173
+ "location_services": {
174
+ "type": "string",
175
+ "enum": [
176
+ "enabled",
177
+ "disabled"
178
+ ]
179
+ },
180
+ "orientation": {
181
+ "type": "string",
182
+ "enum": [
183
+ "portrait",
184
+ "landscape"
185
+ ]
186
+ }
187
+ }
188
+ },
189
+ "notes": {
190
+ "type": "array",
191
+ "items": {
192
+ "type": "string"
193
+ },
194
+ "description": "Human-readable notes about preconditions that cannot be automated (e.g., backend requirements, test account setup)"
195
+ }
196
+ }
197
+ },
198
+ "steps": {
199
+ "type": "array",
200
+ "minItems": 1,
201
+ "description": "Ordered list of test steps to execute.",
202
+ "items": {
203
+ "$ref": "#/definitions/step"
204
+ }
205
+ },
206
+ "assertions": {
207
+ "type": "array",
208
+ "description": "Verifications to run at specific points during the scenario.",
209
+ "items": {
210
+ "$ref": "#/definitions/assertion"
211
+ }
212
+ }
213
+ },
214
+ "definitions": {
215
+ "step": {
216
+ "type": "object",
217
+ "required": [
218
+ "id",
219
+ "action",
220
+ "description"
221
+ ],
222
+ "properties": {
223
+ "id": {
224
+ "type": "string",
225
+ "pattern": "^[a-z][a-z0-9_]*$",
226
+ "description": "Unique step identifier in snake_case (e.g., 'tap_login', 'wait_for_home'). Used for screenshot naming and assertion references."
227
+ },
228
+ "action": {
229
+ "type": "string",
230
+ "enum": [
231
+ "launch_app",
232
+ "tap",
233
+ "long_press",
234
+ "double_tap",
235
+ "type",
236
+ "swipe",
237
+ "scroll_to_element",
238
+ "press_button",
239
+ "wait_for_element",
240
+ "wait_for_element_gone",
241
+ "wait_for_loading_complete",
242
+ "capture_value",
243
+ "clear_app_data",
244
+ "open_url",
245
+ "press_back",
246
+ "dismiss_keyboard",
247
+ "grant_permission",
248
+ "deny_permission"
249
+ ],
250
+ "description": "The automation action to perform. Supported types: wait_for_element, wait_for_element_gone, wait_for_loading_complete, capture_value, clear_app_data, long_press, double_tap, scroll_to_element, press_back, dismiss_keyboard, grant_permission, deny_permission."
251
+ },
252
+ "description": {
253
+ "type": "string",
254
+ "description": "What this step does in plain language — should explain the intent, not just the mechanics"
255
+ },
256
+ "target": {
257
+ "type": "string",
258
+ "description": "Semantic description of the element to interact with or wait for. Used by the AI to locate the element via mobile_list_elements_on_screen."
259
+ },
260
+ "target_pattern": {
261
+ "type": "string",
262
+ "description": "Optional: regex pattern to match against element text when the element cannot be described with a static string (e.g., '\\\\d+ points' for a dynamic points tab label)"
263
+ },
264
+ "value": {
265
+ "type": [
266
+ "string",
267
+ "null"
268
+ ],
269
+ "description": "Input value for the action: text to type, swipe direction (up/down/left/right), button name (HOME/BACK/ENTER), URL to open, or scroll direction"
270
+ },
271
+ "checkpoint": {
272
+ "type": "boolean",
273
+ "default": false,
274
+ "description": "Whether to capture a reference screenshot after this step. Screenshots are saved as mobile-automator/screenshots/<scenario_id>/step_<id>.png"
275
+ },
276
+ "expected_state": {
277
+ "type": "string",
278
+ "description": "Description of the expected visual state after this step completes — used by the executor for semantic validation"
279
+ },
280
+ "optional": {
281
+ "type": "boolean",
282
+ "default": false,
283
+ "description": "If true, failure to find or interact with the target element is silently ignored. Execution continues to the next step. Use for UI elements that only appear sometimes (A/B tests, dialogs, promotions)."
284
+ },
285
+ "condition": {
286
+ "type": "object",
287
+ "description": "When to execute this step. If the condition evaluates to false, the step is skipped.",
288
+ "required": [
289
+ "type"
290
+ ],
291
+ "properties": {
292
+ "type": {
293
+ "type": "string",
294
+ "enum": [
295
+ "device_property",
296
+ "previous_step_skipped",
297
+ "variable_value",
298
+ "element_visible"
299
+ ],
300
+ "description": "The type of condition to evaluate"
301
+ },
302
+ "property": {
303
+ "type": "string",
304
+ "description": "Device property name for device_property conditions (e.g., 'api_level', 'orientation', 'platform')"
305
+ },
306
+ "step_id": {
307
+ "type": "string",
308
+ "description": "Step ID reference for previous_step_skipped conditions"
309
+ },
310
+ "variable_name": {
311
+ "type": "string",
312
+ "description": "Variable name for variable_value conditions"
313
+ },
314
+ "element_description": {
315
+ "type": "string",
316
+ "description": "Element to check for element_visible conditions"
317
+ },
318
+ "operator": {
319
+ "type": "string",
320
+ "enum": [
321
+ "==",
322
+ "!=",
323
+ ">=",
324
+ "<=",
325
+ ">",
326
+ "<"
327
+ ],
328
+ "description": "Comparison operator"
329
+ },
330
+ "value": {
331
+ "description": "Value to compare against",
332
+ "type": [
333
+ "string",
334
+ "number",
335
+ "boolean"
336
+ ]
337
+ }
338
+ }
339
+ },
340
+ "on_failure": {
341
+ "type": "string",
342
+ "enum": [
343
+ "fail",
344
+ "skip",
345
+ "retry"
346
+ ],
347
+ "default": "fail",
348
+ "description": "What to do when this step fails. 'fail' = mark scenario failed (default). 'skip' = continue to next step silently. 'retry' = attempt retry_policy before failing."
349
+ },
350
+ "retry_policy": {
351
+ "type": "object",
352
+ "description": "Retry configuration. Only applicable when on_failure is 'retry'.",
353
+ "required": [
354
+ "max_attempts"
355
+ ],
356
+ "properties": {
357
+ "max_attempts": {
358
+ "type": "integer",
359
+ "minimum": 2,
360
+ "maximum": 5,
361
+ "description": "Total number of attempts including the first (e.g., 3 means 1 initial + 2 retries)"
362
+ },
363
+ "backoff_ms": {
364
+ "type": "integer",
365
+ "minimum": 0,
366
+ "default": 1000,
367
+ "description": "Milliseconds to wait between retry attempts"
368
+ }
369
+ }
370
+ },
371
+ "wait_config": {
372
+ "type": "object",
373
+ "description": "Configuration for wait-type actions (wait_for_element, wait_for_element_gone, wait_for_loading_complete).",
374
+ "required": [
375
+ "type",
376
+ "timeout_ms"
377
+ ],
378
+ "properties": {
379
+ "type": {
380
+ "type": "string",
381
+ "enum": [
382
+ "element_visible",
383
+ "element_gone",
384
+ "loading_complete"
385
+ ],
386
+ "description": "The wait condition to poll for"
387
+ },
388
+ "indicator": {
389
+ "type": "string",
390
+ "enum": [
391
+ "shimmer",
392
+ "spinner",
393
+ "progress_bar",
394
+ "skeleton",
395
+ "any"
396
+ ],
397
+ "default": "any",
398
+ "description": "For loading_complete: which type of loading indicator to wait for disappearance of"
399
+ },
400
+ "timeout_ms": {
401
+ "type": "integer",
402
+ "minimum": 1000,
403
+ "maximum": 60000,
404
+ "description": "Maximum time to wait in milliseconds before failing the step"
405
+ }
406
+ }
407
+ },
408
+ "capture_to": {
409
+ "type": "string",
410
+ "pattern": "^[a-z][a-z0-9_]*$",
411
+ "description": "Variable name to store the captured value in. Must match a variable declared in the root 'variables' block. Used with capture_value action."
412
+ },
413
+ "permission_name": {
414
+ "type": "string",
415
+ "description": "Optional. Names a specific permission for grant_permission/deny_permission actions (e.g., 'camera', 'location'). Semantic-only — does not affect runtime resolution."
416
+ },
417
+ "sub_steps": {
418
+ "type": "array",
419
+ "description": "Nested sub-flow to execute when this step's condition is met. After all sub-steps complete, execution resumes at the next top-level step. Use for NESTED_CONDITIONAL patterns where an entire branching sub-flow must run under a specific condition.",
420
+ "items": {
421
+ "$ref": "#/definitions/step"
422
+ }
423
+ }
424
+ }
425
+ },
426
+ "assertion": {
427
+ "type": "object",
428
+ "required": [
429
+ "id",
430
+ "after_step",
431
+ "type",
432
+ "description"
433
+ ],
434
+ "properties": {
435
+ "id": {
436
+ "type": "string",
437
+ "pattern": "^[a-z][a-z0-9_]*$",
438
+ "description": "Unique assertion identifier in snake_case (e.g., 'assert_login_success')"
439
+ },
440
+ "after_step": {
441
+ "type": "string",
442
+ "description": "Step ID (by name) after which this assertion runs. Must reference a valid step id."
443
+ },
444
+ "type": {
445
+ "type": "string",
446
+ "enum": [
447
+ "screenshot_match",
448
+ "element_exists",
449
+ "element_text",
450
+ "element_not_exists",
451
+ "pattern_match",
452
+ "value_matches_variable",
453
+ "element_count",
454
+ "visual_state",
455
+ "text_changed",
456
+ "element_state",
457
+ "element_visible",
458
+ "text_contains",
459
+ "text_not_empty",
460
+ "element_hint",
461
+ "content_description",
462
+ "list_item_count",
463
+ "list_is_empty",
464
+ "element_fully_visible",
465
+ "color_style",
466
+ "screen_title",
467
+ "alert_present",
468
+ "alert_text",
469
+ "toast_visible",
470
+ "keyboard_visible",
471
+ "has_accessibility_label",
472
+ "permission_dialog_shown",
473
+ "dark_mode_active"
474
+ ],
475
+ "description": "Assertion type. Supported types: screenshot_match, element_exists, element_text, element_not_exists, pattern_match, value_matches_variable, element_count, visual_state, text_changed, element_state, element_visible, text_contains, text_not_empty, element_hint, content_description, list_item_count, list_is_empty, element_fully_visible, color_style, screen_title, alert_present, alert_text, toast_visible, keyboard_visible, has_accessibility_label, permission_dialog_shown, dark_mode_active."
476
+ },
477
+ "description": {
478
+ "type": "string",
479
+ "description": "Human-readable description of what this assertion validates and why it matters"
480
+ },
481
+ "element_description": {
482
+ "type": "string",
483
+ "description": "Semantic description of the element to check (for element_exists, element_text, element_not_exists, element_count, pattern_match, value_matches_variable, text_changed)"
484
+ },
485
+ "expected_value": {
486
+ "type": "string",
487
+ "description": "Expected text value for element_text assertions"
488
+ },
489
+ "pattern": {
490
+ "type": "string",
491
+ "description": "Regex pattern to match against element text for pattern_match assertions. Also used as a format hint in value_matches_variable assertions."
492
+ },
493
+ "variable_name": {
494
+ "type": "string",
495
+ "description": "Variable name (declared in 'variables' block) whose captured value to compare against for value_matches_variable assertions"
496
+ },
497
+ "operator": {
498
+ "type": "string",
499
+ "enum": [
500
+ "==",
501
+ "!=",
502
+ ">=",
503
+ "<=",
504
+ ">",
505
+ "<"
506
+ ],
507
+ "default": "==",
508
+ "description": "Comparison operator for element_count and list_item_count assertions"
509
+ },
510
+ "expected_count": {
511
+ "type": "integer",
512
+ "minimum": 0,
513
+ "description": "Expected element count for element_count assertions"
514
+ },
515
+ "expected_visual_state": {
516
+ "type": "string",
517
+ "enum": [
518
+ "loaded",
519
+ "loading",
520
+ "empty",
521
+ "error"
522
+ ],
523
+ "description": "Expected visual state for visual_state assertions"
524
+ },
525
+ "reference_screenshot": {
526
+ "type": [
527
+ "string",
528
+ "null"
529
+ ],
530
+ "description": "Relative path to the reference baseline screenshot for screenshot_match assertions"
531
+ },
532
+ "tolerance": {
533
+ "type": "number",
534
+ "minimum": 0.0,
535
+ "maximum": 1.0,
536
+ "default": 0.9,
537
+ "description": "Similarity threshold for screenshot_match assertions (0.0 = any match, 1.0 = pixel-perfect)"
538
+ },
539
+ "state_property": {
540
+ "type": "string",
541
+ "enum": [
542
+ "enabled",
543
+ "disabled",
544
+ "selected",
545
+ "not_selected",
546
+ "focused",
547
+ "clickable"
548
+ ],
549
+ "description": "For element_state: the specific interactive state to verify on the target element."
550
+ },
551
+ "expected_visible": {
552
+ "type": "boolean",
553
+ "description": "For element_visible and keyboard_visible: true = must be visible/showing, false = must be hidden/dismissed."
554
+ },
555
+ "expected_substring": {
556
+ "type": "string",
557
+ "description": "For text_contains: the substring to search for within the element's text content. Pass if the element's text includes this value anywhere."
558
+ },
559
+ "expected_text": {
560
+ "type": "string",
561
+ "description": "For toast_visible, alert_text, screen_title, element_hint, content_description: the exact text content to match."
562
+ },
563
+ "expected_theme": {
564
+ "type": "string",
565
+ "enum": [
566
+ "dark",
567
+ "light"
568
+ ],
569
+ "description": "For dark_mode_active: the expected system theme."
570
+ },
571
+ "color_hex": {
572
+ "type": "string",
573
+ "pattern": "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$",
574
+ "description": "For color_style: the expected element color in hex format (e.g., '#0057FF')."
575
+ },
576
+ "label_value": {
577
+ "type": "string",
578
+ "description": "For has_accessibility_label and content_description: the expected accessibility label / content description text."
579
+ },
580
+ "permission_name": {
581
+ "type": "string",
582
+ "description": "For permission_dialog_shown: the permission being requested (e.g., 'camera', 'location', 'microphone', 'notifications', 'contacts')."
583
+ }
584
+ }
585
+ }
586
+ }
587
+ }
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ // Workspace scaffolding for `mauto setup`.
4
+ //
5
+ // Creates the mobile-automator/{scenarios,screenshots,results} tree and a
6
+ // skeleton config.json. Idempotent: re-running never clobbers an existing
7
+ // config's other fields — it only ensures the requested `mode` is set.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const manager = require('../config/manager');
13
+
14
+ const SUBDIRS = ['scenarios', 'screenshots', 'results', 'memory'];
15
+
16
+ function scaffold(projectRoot, { mode } = {}) {
17
+ const created = [];
18
+
19
+ const baseDir = path.join(projectRoot, 'mobile-automator');
20
+ ensureDir(baseDir, created);
21
+ for (const sub of SUBDIRS) {
22
+ ensureDir(path.join(baseDir, sub), created);
23
+ }
24
+
25
+ const cfgPath = manager.configPath(projectRoot);
26
+ let configWritten = false;
27
+ if (!fs.existsSync(cfgPath)) {
28
+ // Fresh skeleton — single source of truth for the day-one config shape.
29
+ const skeleton = {
30
+ mode,
31
+ project_name: null,
32
+ environments: [],
33
+ default_environment: null,
34
+ };
35
+ fs.writeFileSync(cfgPath, JSON.stringify(skeleton, null, 2) + '\n');
36
+ created.push(cfgPath);
37
+ configWritten = true;
38
+ } else {
39
+ // Pre-existing config: only set the mode, preserving every other field.
40
+ manager.set(projectRoot, 'mode', mode);
41
+ }
42
+
43
+ return { created, mode, configWritten };
44
+ }
45
+
46
+ function ensureDir(dir, created) {
47
+ if (!fs.existsSync(dir)) {
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ created.push(dir);
50
+ }
51
+ }
52
+
53
+ module.exports = { scaffold };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // Atomic write: stream into a hidden temp file in the same directory,
7
+ // fsync, then rename over the target. rename(2) within one dir is atomic, so
8
+ // a concurrent reader or a crash sees the old-complete or new-complete file,
9
+ // never a truncated one. On any failure the temp file is cleaned up.
10
+ function atomicWrite(file, contents) {
11
+ fs.mkdirSync(path.dirname(file), { recursive: true });
12
+ const tmp = path.join(
13
+ path.dirname(file),
14
+ `.${path.basename(file)}.tmp.${process.pid}.${Date.now()}`
15
+ );
16
+ let fd;
17
+ try {
18
+ fd = fs.openSync(tmp, 'w');
19
+ fs.writeFileSync(fd, contents);
20
+ fs.fsyncSync(fd);
21
+ fs.closeSync(fd);
22
+ fd = undefined;
23
+ fs.renameSync(tmp, file);
24
+ } catch (e) {
25
+ if (fd !== undefined) {
26
+ try {
27
+ fs.closeSync(fd);
28
+ } catch (_) {
29
+ /* ignore */
30
+ }
31
+ }
32
+ try {
33
+ fs.unlinkSync(tmp);
34
+ } catch (_) {
35
+ /* ignore */
36
+ }
37
+ throw e;
38
+ }
39
+ }
40
+
41
+ module.exports = { atomicWrite };