@typecad/framework-zephyr 1.0.0-alpha.10

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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/dist/async/timer-polyfill.d.ts +10 -0
  4. package/dist/async/timer-polyfill.js +95 -0
  5. package/dist/chips/controllers.d.ts +21 -0
  6. package/dist/chips/controllers.js +66 -0
  7. package/dist/chips/esp32.d.ts +2 -0
  8. package/dist/chips/esp32.js +59 -0
  9. package/dist/chips/esp32s3.d.ts +2 -0
  10. package/dist/chips/esp32s3.js +54 -0
  11. package/dist/chips/index.d.ts +15 -0
  12. package/dist/chips/index.js +45 -0
  13. package/dist/chips/resolve.d.ts +9 -0
  14. package/dist/chips/resolve.js +121 -0
  15. package/dist/chips/types.d.ts +154 -0
  16. package/dist/chips/types.js +14 -0
  17. package/dist/chips/xiao-ble.d.ts +2 -0
  18. package/dist/chips/xiao-ble.js +61 -0
  19. package/dist/debug-codegen.d.ts +33 -0
  20. package/dist/debug-codegen.js +162 -0
  21. package/dist/display/gfx.d.ts +17 -0
  22. package/dist/display/gfx.js +171 -0
  23. package/dist/display/index.d.ts +20 -0
  24. package/dist/display/index.js +53 -0
  25. package/dist/display/profiles.d.ts +25 -0
  26. package/dist/display/profiles.js +41 -0
  27. package/dist/display/touch-adapter.d.ts +9 -0
  28. package/dist/display/touch-adapter.js +95 -0
  29. package/dist/display/ui-adapter.d.ts +21 -0
  30. package/dist/display/ui-adapter.js +534 -0
  31. package/dist/doctor.d.ts +6 -0
  32. package/dist/doctor.js +48 -0
  33. package/dist/dt-config/kconfig.d.ts +27 -0
  34. package/dist/dt-config/kconfig.js +283 -0
  35. package/dist/dt-config/overlay.d.ts +35 -0
  36. package/dist/dt-config/overlay.js +246 -0
  37. package/dist/framework.manifest.d.ts +101 -0
  38. package/dist/framework.manifest.js +536 -0
  39. package/dist/index.d.ts +6 -0
  40. package/dist/index.js +17 -0
  41. package/dist/lowering/adc.d.ts +19 -0
  42. package/dist/lowering/adc.js +76 -0
  43. package/dist/lowering/ble.d.ts +20 -0
  44. package/dist/lowering/ble.js +419 -0
  45. package/dist/lowering/board.d.ts +9 -0
  46. package/dist/lowering/board.js +33 -0
  47. package/dist/lowering/gpio.d.ts +12 -0
  48. package/dist/lowering/gpio.js +123 -0
  49. package/dist/lowering/http.d.ts +23 -0
  50. package/dist/lowering/http.js +482 -0
  51. package/dist/lowering/i2c.d.ts +15 -0
  52. package/dist/lowering/i2c.js +114 -0
  53. package/dist/lowering/index.d.ts +29 -0
  54. package/dist/lowering/index.js +93 -0
  55. package/dist/lowering/interrupts.d.ts +21 -0
  56. package/dist/lowering/interrupts.js +91 -0
  57. package/dist/lowering/mqtt.d.ts +19 -0
  58. package/dist/lowering/mqtt.js +366 -0
  59. package/dist/lowering/power.d.ts +9 -0
  60. package/dist/lowering/power.js +64 -0
  61. package/dist/lowering/preferences.d.ts +23 -0
  62. package/dist/lowering/preferences.js +345 -0
  63. package/dist/lowering/pulse.d.ts +7 -0
  64. package/dist/lowering/pulse.js +82 -0
  65. package/dist/lowering/pwm.d.ts +15 -0
  66. package/dist/lowering/pwm.js +59 -0
  67. package/dist/lowering/random.d.ts +18 -0
  68. package/dist/lowering/random.js +129 -0
  69. package/dist/lowering/spi.d.ts +17 -0
  70. package/dist/lowering/spi.js +113 -0
  71. package/dist/lowering/timing.d.ts +9 -0
  72. package/dist/lowering/timing.js +63 -0
  73. package/dist/lowering/tone.d.ts +10 -0
  74. package/dist/lowering/tone.js +50 -0
  75. package/dist/lowering/uart.d.ts +15 -0
  76. package/dist/lowering/uart.js +102 -0
  77. package/dist/lowering/util.d.ts +4 -0
  78. package/dist/lowering/util.js +12 -0
  79. package/dist/lowering/wdt.d.ts +15 -0
  80. package/dist/lowering/wdt.js +80 -0
  81. package/dist/lowering/wifi.d.ts +21 -0
  82. package/dist/lowering/wifi.js +447 -0
  83. package/dist/lowering/worker-backing.d.ts +14 -0
  84. package/dist/lowering/worker-backing.js +79 -0
  85. package/dist/lowering/worker.d.ts +6 -0
  86. package/dist/lowering/worker.js +14 -0
  87. package/dist/strategy.d.ts +168 -0
  88. package/dist/strategy.js +1094 -0
  89. package/dist/toolchain/compat.d.ts +38 -0
  90. package/dist/toolchain/compat.js +133 -0
  91. package/dist/toolchain/debug-config.d.ts +82 -0
  92. package/dist/toolchain/debug-config.js +359 -0
  93. package/dist/toolchain/index.d.ts +73 -0
  94. package/dist/toolchain/index.js +444 -0
  95. package/dist/toolchain/scaffold.d.ts +15 -0
  96. package/dist/toolchain/scaffold.js +196 -0
  97. package/dist/toolchain/west-discover.d.ts +40 -0
  98. package/dist/toolchain/west-discover.js +203 -0
  99. package/dist/toolchain/west-spawn.d.ts +39 -0
  100. package/dist/toolchain/west-spawn.js +117 -0
  101. package/package.json +82 -0
  102. package/src/async/timer-polyfill.ts +107 -0
  103. package/src/chips/controllers.ts +74 -0
  104. package/src/chips/esp32.ts +62 -0
  105. package/src/chips/esp32s3.ts +57 -0
  106. package/src/chips/index.ts +54 -0
  107. package/src/chips/resolve.ts +146 -0
  108. package/src/chips/types.ts +163 -0
  109. package/src/chips/xiao-ble.ts +64 -0
  110. package/src/debug-codegen.ts +207 -0
  111. package/src/display/gfx.ts +190 -0
  112. package/src/display/index.ts +70 -0
  113. package/src/display/profiles.ts +63 -0
  114. package/src/display/touch-adapter.ts +100 -0
  115. package/src/display/ui-adapter.ts +558 -0
  116. package/src/doctor.ts +56 -0
  117. package/src/dt-config/kconfig.ts +308 -0
  118. package/src/dt-config/overlay.ts +293 -0
  119. package/src/framework.manifest.ts +558 -0
  120. package/src/index.ts +29 -0
  121. package/src/lowering/adc.ts +102 -0
  122. package/src/lowering/ble.ts +426 -0
  123. package/src/lowering/board.ts +38 -0
  124. package/src/lowering/gpio.ts +148 -0
  125. package/src/lowering/http.ts +488 -0
  126. package/src/lowering/i2c.ts +126 -0
  127. package/src/lowering/index.ts +86 -0
  128. package/src/lowering/interrupts.ts +114 -0
  129. package/src/lowering/mqtt.ts +371 -0
  130. package/src/lowering/power.ts +70 -0
  131. package/src/lowering/preferences.ts +354 -0
  132. package/src/lowering/pulse.ts +93 -0
  133. package/src/lowering/pwm.ts +74 -0
  134. package/src/lowering/random.ts +135 -0
  135. package/src/lowering/spi.ts +124 -0
  136. package/src/lowering/timing.ts +72 -0
  137. package/src/lowering/tone.ts +60 -0
  138. package/src/lowering/uart.ts +111 -0
  139. package/src/lowering/util.ts +12 -0
  140. package/src/lowering/wdt.ts +84 -0
  141. package/src/lowering/wifi.ts +452 -0
  142. package/src/lowering/worker-backing.ts +94 -0
  143. package/src/lowering/worker.ts +17 -0
  144. package/src/strategy.ts +1265 -0
  145. package/src/toolchain/compat.ts +145 -0
  146. package/src/toolchain/debug-config.ts +399 -0
  147. package/src/toolchain/index.ts +482 -0
  148. package/src/toolchain/scaffold.ts +196 -0
  149. package/src/toolchain/west-discover.ts +233 -0
  150. package/src/toolchain/west-spawn.ts +141 -0
@@ -0,0 +1,196 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Zephyr project scaffolding
3
+ //
4
+ // Writes the CMakeLists.txt + prj.conf around the cuttlefish-emitted src/main.cpp
5
+ // so `west build` has a valid Zephyr application. Idempotent: overwrites the
6
+ // generated files only when their content changes (avoids invalidating the
7
+ // Ninja incremental build's mtime-based dependency tracking).
8
+ // ---------------------------------------------------------------------------
9
+ import { writeFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { resolveKconfigFragments } from '../dt-config/kconfig.js';
12
+ /** Write a file only if the content differs from the existing file.
13
+ * Returns true when the file was written (content changed or file was new). */
14
+ export function writeIfChanged(filePath, content) {
15
+ if (existsSync(filePath)) {
16
+ try {
17
+ if (readFileSync(filePath, 'utf8') === content)
18
+ return false;
19
+ }
20
+ catch {
21
+ // Read failed — fall through to write.
22
+ }
23
+ }
24
+ writeFileSync(filePath, content);
25
+ return true;
26
+ }
27
+ /**
28
+ * Concatenate all emitted source under src/ so the scaffold can detect which
29
+ * peripherals the program actually uses. The cuttlefish lowering emits
30
+ * well-known driver API tokens (adc_read, spi_transceive, bt_*, …), so scanning
31
+ * the post-transpile source is an authoritative usage signal — and it keeps the
32
+ * scaffold self-contained (no need to thread analysis through the toolchain
33
+ * contract). Returns '' when no sources exist yet (first prepare call).
34
+ */
35
+ function readEmittedSources(srcDir) {
36
+ if (!existsSync(srcDir))
37
+ return '';
38
+ let out = '';
39
+ for (const name of readdirSync(srcDir)) {
40
+ if (name.endsWith('.cpp') || name.endsWith('.c')) {
41
+ try {
42
+ out += readFileSync(join(srcDir, name), 'utf8');
43
+ }
44
+ catch {
45
+ // ignore unreadable files
46
+ }
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ /**
52
+ * Emit the Zephyr application skeleton around the generated src/main.cpp.
53
+ *
54
+ * Layout written:
55
+ * <projectRoot>/
56
+ * CMakeLists.txt (find_package(Zephyr), target_sources app ← src/*.cpp)
57
+ * prj.conf (CONFIG_* for GPIO + C++ + libc)
58
+ * src/main.cpp (owned by cuttlefish's emit pipeline — NOT touched here)
59
+ *
60
+ * Idempotent. Mirrors scaffoldEspIdfProject's writeIfChanged discipline.
61
+ */
62
+ export function scaffoldZephyrProject(projectRoot, debug = false, userKconfig, psram) {
63
+ const srcDir = join(projectRoot, 'src');
64
+ if (!existsSync(srcDir))
65
+ mkdirSync(srcDir, { recursive: true });
66
+ // Detect which peripherals the program actually uses by scanning the emitted
67
+ // source. The cuttlefish lowering emits well-known driver API tokens, so this
68
+ // is authoritative. Usage-gating the Kconfig symbols keeps a GPIO-only
69
+ // program from pulling in (and linking) stacks it doesn't need — notably
70
+ // NimBLE (CONFIG_BT), whose Espressif prebuilt blobs are not always present
71
+ // in a workspace. The symbol set itself lives in resolveKconfigFragments
72
+ // (dt-config/kconfig.ts), unit-tested separately.
73
+ const src = readEmittedSources(srcDir);
74
+ // Boundary-anchored token scan: a bare `src.includes('power_')` would match
75
+ // `tx_power_dbm` (emitted by the WiFi shim) and flip CONFIG_PM on for a
76
+ // WiFi-only program — on the ESP32-S3 that spins the PM soft-off retry loop
77
+ // forever and starves the app. Anchor each `<prefix>_` token at a leading
78
+ // word boundary so it matches the intended driver/HAL symbol, not a suffix.
79
+ const uses = (token) => {
80
+ if (token.endsWith('_')) {
81
+ return new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(src);
82
+ }
83
+ return src.includes(token);
84
+ };
85
+ const usage = {
86
+ usesAdc: uses('adc_'),
87
+ usesPwm: uses('pwm_'),
88
+ usesI2c: uses('i2c_'),
89
+ usesSpi: uses('spi_'),
90
+ usesUart: uses('uart_'),
91
+ usesWdt: uses('wdt_'),
92
+ usesBle: uses('bt_') || uses('bt_gatt') || uses('bt_le_'),
93
+ usesDisplay: uses('display_write') || uses('display_init') || uses('display_fill_rect') || uses('__tc_display_dev') || uses('CuttlefishDisplayTarget'),
94
+ usesTouch: uses('ft6336u') || uses('touch_'),
95
+ // Power tokens: k_sleep + pm_state_force / PM_STATE_* (what power.ts emits).
96
+ // The old `power_` token matched nothing the power HAL emits and collides
97
+ // with tx_power_dbm — removed.
98
+ usesPower: uses('pm_') || uses('k_sleep') || uses('PM_STATE_'),
99
+ usesWifi: uses('wifi_') || uses('net_mgmt') || uses('conn_mgr'),
100
+ // HTTP: the __tc_http_* shim + http_client_req + getaddrinfo. The '_'-anchored
101
+ // 'http_' token matches __tc_http_* and http_client_req (the core HTTP lib
102
+ // symbol), mirroring how wifi_ detects the wifi shim. An http-only program
103
+ // still pulls the networking stack even without usesWifi.
104
+ usesHttp: uses('http_') || uses('__tc_http') || uses('http_client_req'),
105
+ // MQTT: the __tc_mqtt_* shim + mqtt_connect/mqtt_publish/mqtt_subscribe.
106
+ usesMqtt: uses('mqtt_') || uses('__tc_mqtt') || uses('mqtt_connect'),
107
+ // Preferences: the __tc_prefs_* shim + the settings_* API the shim calls.
108
+ // settings_load/save_one/delete + SETTINGS_STATIC_HANDLER_DEFINE all emit
109
+ // `settings_` symbols; __tc_prefs catches the typed accessors (put_int etc.
110
+ // template into __tc_prefs_put<...>, which keeps the __tc_prefs token).
111
+ usesPreferences: uses('settings_') || uses('__tc_prefs'),
112
+ // Random: the __tc_rand_* shim + the sys_rand_get entropy tap it seeds from.
113
+ usesRandom: uses('__tc_rand') || uses('sys_rand_get'),
114
+ psram,
115
+ };
116
+ let changed = false;
117
+ // ── Root CMakeLists.txt ─────────────────────────────────────────────────
118
+ // The canonical Zephyr CMake application. GLOB src/*.cpp so future multi-
119
+ // file emits are picked up automatically; cuttlefish owns the file contents.
120
+ const cmakeLists = [
121
+ '# Auto-generated by @typecad/framework-zephyr from cuttlefish.config.ts.',
122
+ 'cmake_minimum_required(VERSION 3.20.0)',
123
+ '',
124
+ 'find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})',
125
+ '',
126
+ 'project(zephyr_app)',
127
+ '',
128
+ '# Collect cuttlefish-emitted sources.',
129
+ 'file(GLOB app_sources src/*.cpp src/*.c)',
130
+ '',
131
+ 'target_sources(app PRIVATE ${app_sources})',
132
+ // When PSRAM is configured, define BOARD_HAS_PSRAM so the UI runtime's
133
+ // PSRAM canvas allocator (ui_create_canvas_best) is compiled in.
134
+ ...(psram ? ['', '# PSRAM enabled: activate the runtime PSRAM canvas paths.', 'target_compile_definitions(app PRIVATE BOARD_HAS_PSRAM)', ''] : ['']),
135
+ ].join('\n');
136
+ if (writeIfChanged(join(projectRoot, 'CMakeLists.txt'), cmakeLists))
137
+ changed = true;
138
+ // ── prj.conf ────────────────────────────────────────────────────────────
139
+ // Driver Kconfig symbols are usage-gated on the emitted source: only a
140
+ // peripheral whose driver API the program actually references is enabled.
141
+ // Zephyr's Kconfig treats an enabled-but-unused driver as harmless, BUT
142
+ // several driver stacks pull in large/specific dependencies — notably
143
+ // NimBLE (CONFIG_BT) needs Espressif prebuilt blobs that are not always
144
+ // fetched (`west blobs fetch hal_espressif`). Usage-gating keeps a GPIO-only
145
+ // blink from requiring those, and shrinks the link for every program. The
146
+ // core GPIO driver and C++ support stay unconditional. Symbol selection is
147
+ // delegated to resolveKconfigFragments (unit-tested in dt-config/kconfig).
148
+ const symbols = resolveKconfigFragments(usage, debug);
149
+ const prjConf = [
150
+ '# Auto-generated by @typecad/framework-zephyr from cuttlefish.config.ts.',
151
+ '# Edit in cuttlefish.config.ts (frameworkData), not here.',
152
+ '# Driver symbols are usage-gated on the emitted source — only peripherals',
153
+ '# the program references are enabled.',
154
+ '',
155
+ '# Route printf/stdout to the console UART (needed by the @typecad/expect',
156
+ '# test runner protocol, which uses printf via __tc_print/__tc_println).',
157
+ 'CONFIG_STDOUT_CONSOLE=y',
158
+ 'CONFIG_PRINTK=y',
159
+ '',
160
+ ];
161
+ // Emit a section header before the BT block when present.
162
+ let btHeaderEmitted = false;
163
+ let wifiHeaderEmitted = false;
164
+ for (const [sym, val] of symbols) {
165
+ if (sym === 'CONFIG_BT' && !btHeaderEmitted) {
166
+ prjConf.push('', '# Bluetooth (NimBLE peripheral).');
167
+ btHeaderEmitted = true;
168
+ }
169
+ if (sym === 'CONFIG_WIFI' && !wifiHeaderEmitted) {
170
+ prjConf.push('', '# WiFi / networking (conn_mgr + esp32 wifi driver).');
171
+ wifiHeaderEmitted = true;
172
+ }
173
+ // Skip auto-detected symbols that the user explicitly overrides
174
+ // in cuttlefish.config.ts zephyr.kconfig — the user value is
175
+ // emitted in the User Kconfig section below and takes precedence.
176
+ if (userKconfig && userKconfig.hasOwnProperty(sym))
177
+ continue;
178
+ prjConf.push(`${sym}=${val}`);
179
+ }
180
+ // Emit user-specified Kconfig from cuttlefish.config.ts zephyr.kconfig.
181
+ // These override any matching auto-detected symbol (skipped above).
182
+ if (userKconfig) {
183
+ prjConf.push('', '# User Kconfig (cuttlefish.config.ts → zephyr.kconfig).');
184
+ for (const [sym, val] of Object.entries(userKconfig)) {
185
+ prjConf.push(`${sym}=${val}`);
186
+ }
187
+ }
188
+ // CONFIG_BT_DEVICE_NAME is a string value not produced by the resolver — add
189
+ // it after the BT block when BLE is used (parity with the previous inline form).
190
+ if (usage.usesBle)
191
+ prjConf.push('CONFIG_BT_DEVICE_NAME="TypeCAD"');
192
+ prjConf.push('');
193
+ if (writeIfChanged(join(projectRoot, 'prj.conf'), prjConf.join('\n')))
194
+ changed = true;
195
+ return changed;
196
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A discovered, usable west installation. `mode` tells the caller how to
3
+ * invoke it: 'launcher' = call `westExecutable` directly; 'module' = call
4
+ * `pythonExecutable -m west`.
5
+ */
6
+ export interface WestInstall {
7
+ mode: 'launcher' | 'module';
8
+ /** Absolute path to a `west` launcher (mode 'launcher') or undefined. */
9
+ westExecutable?: string;
10
+ /** Absolute path to a Python interpreter with west installed (mode 'module'). */
11
+ pythonExecutable?: string;
12
+ /** Absolute path to the Zephyr SDK root (for $ZEPHYR_BASE), if found. */
13
+ zephyrBase?: string;
14
+ /** Which discovery strategy found this install. */
15
+ source: 'path' | 'zephyr-base-venv' | 'well-known' | 'system-python';
16
+ }
17
+ /** True if `dir` looks like a Zephyr SDK root: has CMakeLists.txt and the
18
+ * kernel header. */
19
+ export declare function isZephyrBase(dir: string): boolean;
20
+ export declare function discoverFromPath(): WestInstall | null;
21
+ /** The canonical Zephyr workspace layout puts the venv beside the SDK:
22
+ * <workspace>/{.venv, zephyr}. So ${ZEPHYR_BASE}/../.venv is the venv. */
23
+ export declare function discoverFromZephyrBase(): WestInstall | null;
24
+ /** Candidate Zephyr workspace directories. Each may contain both `.venv/`
25
+ * and `zephyr/` (the SDK). Exported for test injection. */
26
+ export declare function wellKnownWorkspaces(): string[];
27
+ export declare function discoverFromWellKnown(workspaces?: string[]): WestInstall | null;
28
+ /** Candidate system Python interpreters to probe with `-m west`. */
29
+ export declare function systemPythons(): string[];
30
+ export declare function discoverFromSystemPython(pythons?: string[]): WestInstall | null;
31
+ /** Clear the process-local discovery cache (for tests). */
32
+ export declare function resetWestDiscoveryCache(): void;
33
+ /**
34
+ * Try each discovery strategy in order. The first usable install wins.
35
+ * Result is memoized for the process lifetime (west installs don't move).
36
+ *
37
+ * Order: PATH → $ZEPHYR_BASE venv → well-known workspaces → system pythons.
38
+ * Returns null when no usable west install is found.
39
+ */
40
+ export declare function discoverWest(): WestInstall | null;
@@ -0,0 +1,203 @@
1
+ // ---------------------------------------------------------------------------
2
+ // west discovery — find a usable `west` (and the Zephyr SDK / ZEPHYR_BASE)
3
+ //
4
+ // west installs into a Python venv that must be activated before `west` is on
5
+ // PATH. We resolve a working invocation WITHOUT requiring the user to have
6
+ // activated the venv, by preferring the robust `<python> -m west` form: it
7
+ // sidesteps shebang-launcher fragility on Windows and works with any venv
8
+ // once we know which Python interpreter has west installed.
9
+ //
10
+ // Discovery cascade (first usable wins):
11
+ // 1. `west` already on PATH (env already activated / global install).
12
+ // 2. $ZEPHYR_BASE venv: ${ZEPHYR_BASE}/../.venv/<python> -m west.
13
+ // 3. Well-known workspace layouts: ~/zephyrproject/.venv, /opt/zephyrproject/.
14
+ // venv, etc.
15
+ // 4. System pythons (`python`, `python3`, `py`) via `-m west`.
16
+ //
17
+ // Leaner than the ESP-IDF equivalent: west needs no env sourcing (no 15s
18
+ // export.sh) — only the right interpreter + ZEPHYR_BASE.
19
+ // ---------------------------------------------------------------------------
20
+ import { existsSync } from 'node:fs';
21
+ import { dirname, join } from 'node:path';
22
+ import { homedir } from 'node:os';
23
+ import { spawnSync } from 'node:child_process';
24
+ const IS_WIN = process.platform === 'win32';
25
+ /** The Python executable name inside a venv's bin/ (POSIX) or Scripts/ (Win). */
26
+ function venvPython(venvDir) {
27
+ return join(venvDir, IS_WIN ? 'Scripts' : 'bin', IS_WIN ? 'python.exe' : 'python');
28
+ }
29
+ /** True if `exe` runs `python -m west --version` successfully. */
30
+ function pythonRunsWest(exe) {
31
+ try {
32
+ const r = spawnSync(exe, ['-m', 'west', '--version'], {
33
+ encoding: 'utf8',
34
+ timeout: 15_000,
35
+ windowsHide: true,
36
+ });
37
+ return r.status === 0;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ /** True if `cmd` runs `west --version` successfully. */
44
+ function westOnPath(cmd) {
45
+ try {
46
+ const r = spawnSync(cmd, ['--version'], {
47
+ encoding: 'utf8',
48
+ timeout: 15_000,
49
+ shell: IS_WIN,
50
+ windowsHide: true,
51
+ });
52
+ return r.status === 0;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ /** True if `dir` looks like a Zephyr SDK root: has CMakeLists.txt and the
59
+ * kernel header. */
60
+ export function isZephyrBase(dir) {
61
+ if (!dir)
62
+ return false;
63
+ return (existsSync(join(dir, 'CMakeLists.txt')) &&
64
+ existsSync(join(dir, 'include', 'zephyr', 'kernel.h')));
65
+ }
66
+ // ── Strategy 1: `west` on PATH ──────────────────────────────────────────────
67
+ export function discoverFromPath() {
68
+ const which = spawnSync(IS_WIN ? 'where' : 'which', ['west'], {
69
+ encoding: 'utf8',
70
+ shell: true,
71
+ windowsHide: true,
72
+ });
73
+ if (which.status !== 0)
74
+ return null;
75
+ const lines = (which.stdout ?? '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
76
+ for (const line of lines) {
77
+ if (!existsSync(line))
78
+ continue;
79
+ if (!westOnPath(line))
80
+ continue;
81
+ return {
82
+ mode: 'launcher',
83
+ westExecutable: line,
84
+ zephyrBase: process.env.ZEPHYR_BASE || undefined,
85
+ source: 'path',
86
+ };
87
+ }
88
+ return null;
89
+ }
90
+ // ── Strategy 2: $ZEPHYR_BASE sibling venv ───────────────────────────────────
91
+ /** The canonical Zephyr workspace layout puts the venv beside the SDK:
92
+ * <workspace>/{.venv, zephyr}. So ${ZEPHYR_BASE}/../.venv is the venv. */
93
+ export function discoverFromZephyrBase() {
94
+ const zb = process.env.ZEPHYR_BASE;
95
+ if (!zb || !isZephyrBase(zb))
96
+ return null;
97
+ const workspaceDir = dirname(zb);
98
+ const venvDir = join(workspaceDir, '.venv');
99
+ const py = venvPython(venvDir);
100
+ if (!existsSync(py) || !pythonRunsWest(py))
101
+ return null;
102
+ return {
103
+ mode: 'module',
104
+ pythonExecutable: py,
105
+ zephyrBase: zb,
106
+ source: 'zephyr-base-venv',
107
+ };
108
+ }
109
+ // ── Strategy 3: well-known workspace layouts ───────────────────────────────
110
+ /** Candidate Zephyr workspace directories. Each may contain both `.venv/`
111
+ * and `zephyr/` (the SDK). Exported for test injection. */
112
+ export function wellKnownWorkspaces() {
113
+ const home = homedir();
114
+ if (IS_WIN) {
115
+ return [
116
+ join(home, 'zephyrproject'),
117
+ join(home, 'zephyr'),
118
+ 'C:\\zephyrproject',
119
+ 'C:\\zephyr',
120
+ ];
121
+ }
122
+ return [
123
+ join(home, 'zephyrproject'),
124
+ join(home, 'zephyr'),
125
+ '/opt/zephyrproject',
126
+ '/opt/zephyr',
127
+ ];
128
+ }
129
+ export function discoverFromWellKnown(workspaces = wellKnownWorkspaces()) {
130
+ for (const ws of workspaces) {
131
+ const venvDir = join(ws, '.venv');
132
+ const py = venvPython(venvDir);
133
+ if (!existsSync(py) || !pythonRunsWest(py))
134
+ continue;
135
+ // Resolve ZEPHYR_BASE if the SDK sits beside the venv.
136
+ const zb = join(ws, 'zephyr');
137
+ return {
138
+ mode: 'module',
139
+ pythonExecutable: py,
140
+ zephyrBase: isZephyrBase(zb) ? zb : undefined,
141
+ source: 'well-known',
142
+ };
143
+ }
144
+ return null;
145
+ }
146
+ // ── Strategy 4: system pythons via `-m west` ────────────────────────────────
147
+ /** Candidate system Python interpreters to probe with `-m west`. */
148
+ export function systemPythons() {
149
+ if (IS_WIN)
150
+ return ['py', 'python', 'python3'];
151
+ return ['python3', 'python'];
152
+ }
153
+ export function discoverFromSystemPython(pythons = systemPythons()) {
154
+ for (const py of pythons) {
155
+ if (!pythonRunsWest(py))
156
+ continue;
157
+ return {
158
+ mode: 'module',
159
+ pythonExecutable: py,
160
+ zephyrBase: process.env.ZEPHYR_BASE || undefined,
161
+ source: 'system-python',
162
+ };
163
+ }
164
+ return null;
165
+ }
166
+ // ── Top-level cascade ────────────────────────────────────────────────────────
167
+ let cachedDiscover;
168
+ /** Clear the process-local discovery cache (for tests). */
169
+ export function resetWestDiscoveryCache() {
170
+ cachedDiscover = undefined;
171
+ }
172
+ /**
173
+ * Try each discovery strategy in order. The first usable install wins.
174
+ * Result is memoized for the process lifetime (west installs don't move).
175
+ *
176
+ * Order: PATH → $ZEPHYR_BASE venv → well-known workspaces → system pythons.
177
+ * Returns null when no usable west install is found.
178
+ */
179
+ export function discoverWest() {
180
+ if (cachedDiscover !== undefined)
181
+ return cachedDiscover;
182
+ const strategies = [
183
+ discoverFromPath,
184
+ discoverFromZephyrBase,
185
+ discoverFromWellKnown,
186
+ discoverFromSystemPython,
187
+ ];
188
+ for (const strat of strategies) {
189
+ let install = null;
190
+ try {
191
+ install = strat();
192
+ }
193
+ catch {
194
+ install = null;
195
+ }
196
+ if (install) {
197
+ cachedDiscover = install;
198
+ return install;
199
+ }
200
+ }
201
+ cachedDiscover = null;
202
+ return null;
203
+ }
@@ -0,0 +1,39 @@
1
+ import type { SpawnSyncOptions } from 'node:child_process';
2
+ import { type WestInstall } from './west-discover.js';
3
+ export interface WestInvocation {
4
+ /** The command to spawnSync: a `west` launcher or a Python interpreter. */
5
+ command: string;
6
+ /** Args for the command (includes `-m west` when in module mode). */
7
+ args: string[];
8
+ /** Base spawn options with cwd/timeout/encoding/stdio, plus the env carrying
9
+ * ZEPHYR_BASE when discovered. */
10
+ options: SpawnSyncOptions;
11
+ /** The install the invocation was built from, for surfacing to the user. */
12
+ install: WestInstall;
13
+ }
14
+ /**
15
+ * The env to pass to the west spawn: the process env, with ZEPHYR_BASE injected
16
+ * when a SDK root was discovered, AND the venv's bin/Scripts dir prepended to
17
+ * PATH when west was found via a venv Python.
18
+ *
19
+ * The PATH prepend matters: `west flash` shells out to bare runner tools
20
+ * (`esptool`, `openocd`, `nrfjprog`, …) via check_call, so they resolve from
21
+ * PATH. Without the prepend, the user's PATH may surface a *different* tool
22
+ * ahead of the venv's — e.g. an older esptool whose argument spelling is
23
+ * incompatible with the runner. Putting the venv's bin first makes west's
24
+ * delegated subprocesses resolve to the same versions west itself runs under.
25
+ */
26
+ export declare function buildEnv(install: WestInstall): NodeJS.ProcessEnv;
27
+ /**
28
+ * Build a spawn invocation that runs `west <westArgs>`.
29
+ *
30
+ * `baseOptions` carries the cwd/timeout/stdio settings the caller wants;
31
+ * westSpawn preserves them and injects the discovery env. The returned object
32
+ * destructures directly into spawnSync:
33
+ *
34
+ * const inv = westSpawn(['build', '-b', 'xiao_ble', projectRoot], { cwd, timeout });
35
+ * spawnSync(inv.command, inv.args, inv.options);
36
+ *
37
+ * Throws a clear, actionable error when no usable west install is discovered.
38
+ */
39
+ export declare function westSpawn(westArgs: string[], baseOptions: SpawnSyncOptions): WestInvocation;
@@ -0,0 +1,117 @@
1
+ // ---------------------------------------------------------------------------
2
+ // west spawn helper — build a spawnSync invocation for `west <args>`
3
+ //
4
+ // Mirrors framework-esp32's idfSpawn(): given the discovered WestInstall,
5
+ // produce {command, args, env} that the toolchain destructures into spawnSync.
6
+ //
7
+ // Two modes:
8
+ // - 'launcher': spawn the `west` launcher directly (mode 'launcher').
9
+ // - 'module' : spawn `<python> -m west ...` (mode 'module'). This is the
10
+ // robust cross-platform form — it activates the venv's
11
+ // interpreter for the one process without sourcing anything.
12
+ //
13
+ // ZEPHYR_BASE is injected into the spawn env when a SDK root was discovered
14
+ // (find_package(Zephyr) needs it). west's prj.conf/CMakeLists are found via
15
+ // the project dir regardless.
16
+ // ---------------------------------------------------------------------------
17
+ import { dirname } from 'node:path';
18
+ import { discoverWest } from './west-discover.js';
19
+ /**
20
+ * The Scripts/ (Windows) or bin/ (POSIX) directory of the venv the discovered
21
+ * west runs under. `pythonExecutable` lives in that directory, so it is its
22
+ * dirname. Returns undefined for launcher-mode installs where no venv is known.
23
+ */
24
+ function venvBinDir(install) {
25
+ return install.pythonExecutable ? dirname(install.pythonExecutable) : undefined;
26
+ }
27
+ /**
28
+ * The env to pass to the west spawn: the process env, with ZEPHYR_BASE injected
29
+ * when a SDK root was discovered, AND the venv's bin/Scripts dir prepended to
30
+ * PATH when west was found via a venv Python.
31
+ *
32
+ * The PATH prepend matters: `west flash` shells out to bare runner tools
33
+ * (`esptool`, `openocd`, `nrfjprog`, …) via check_call, so they resolve from
34
+ * PATH. Without the prepend, the user's PATH may surface a *different* tool
35
+ * ahead of the venv's — e.g. an older esptool whose argument spelling is
36
+ * incompatible with the runner. Putting the venv's bin first makes west's
37
+ * delegated subprocesses resolve to the same versions west itself runs under.
38
+ */
39
+ export function buildEnv(install) {
40
+ const env = { ...process.env };
41
+ if (install.zephyrBase && !env.ZEPHYR_BASE) {
42
+ env.ZEPHYR_BASE = install.zephyrBase;
43
+ }
44
+ const bin = venvBinDir(install);
45
+ if (bin) {
46
+ const sep = process.platform === 'win32' ? ';' : ':';
47
+ // On Windows the PATH environment variable may be cased as `Path` (the
48
+ // registry-native form, the only one populated when node is launched from
49
+ // PowerShell/cmd) or `PATH` (POSIX form, set by Git Bash). Writing only one
50
+ // casing can leave the other stale/empty, which under PowerShell would drop
51
+ // the user's real PATH (cmake, ninja, …) — breaking `west` configure. Read
52
+ // whichever casing is populated and write that same casing back, preserving
53
+ // the full existing value with the venv dir prepended.
54
+ const existing = env.Path ?? env.PATH ?? '';
55
+ const updated = bin + sep + existing;
56
+ if (env.Path !== undefined || (env.PATH === undefined && process.platform === 'win32')) {
57
+ env.Path = updated;
58
+ }
59
+ else {
60
+ env.PATH = updated;
61
+ }
62
+ }
63
+ return env;
64
+ }
65
+ /**
66
+ * Build a spawn invocation that runs `west <westArgs>`.
67
+ *
68
+ * `baseOptions` carries the cwd/timeout/stdio settings the caller wants;
69
+ * westSpawn preserves them and injects the discovery env. The returned object
70
+ * destructures directly into spawnSync:
71
+ *
72
+ * const inv = westSpawn(['build', '-b', 'xiao_ble', projectRoot], { cwd, timeout });
73
+ * spawnSync(inv.command, inv.args, inv.options);
74
+ *
75
+ * Throws a clear, actionable error when no usable west install is discovered.
76
+ */
77
+ export function westSpawn(westArgs, baseOptions) {
78
+ const install = discoverWest();
79
+ if (!install) {
80
+ throw new Error([
81
+ 'west (the Zephyr build tool) was not found.',
82
+ '',
83
+ 'cuttlefish looked for it on PATH, in $ZEPHYR_BASE/.venv, in common',
84
+ 'Zephyr workspace dirs (~/zephyrproject/.venv), and as a system',
85
+ "Python module (`python -m west`). To fix:",
86
+ '',
87
+ ' • Run the typeCAD Zephyr installer (one command, any OS — needs Node ≥18):',
88
+ ' node packages/zephyr-installer/install.mjs',
89
+ ' then `micromamba activate zephyr` and retry.',
90
+ ' • Or activate an existing Zephyr venv in this shell, or',
91
+ ' • Or set ZEPHYR_BASE to your Zephyr SDK root (the venv at $ZEPHYR_BASE/../.venv is then used), or',
92
+ ' • Or install west into a discoverable Python: pip install west',
93
+ '',
94
+ 'See https://docs.zephyrproject.org/latest/develop/getting_started/index.html',
95
+ ].join('\n'));
96
+ }
97
+ const env = buildEnv(install);
98
+ // Strip `shell` if present — we pass absolute paths / known commands, and
99
+ // an explicit shell changes arg-quoting semantics on Windows.
100
+ const { shell: _drop, ...optsWithoutShell } = baseOptions;
101
+ if (install.mode === 'launcher' && install.westExecutable) {
102
+ return {
103
+ command: install.westExecutable,
104
+ args: westArgs,
105
+ options: { ...optsWithoutShell, env },
106
+ install,
107
+ };
108
+ }
109
+ // module mode: <python> -m west <args>
110
+ const py = install.pythonExecutable;
111
+ return {
112
+ command: py,
113
+ args: ['-m', 'west', ...westArgs],
114
+ options: { ...optsWithoutShell, env },
115
+ install,
116
+ };
117
+ }