howcode 0.1.1 → 0.1.3

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 (3) hide show
  1. package/README.md +32 -9
  2. package/lib/howcode.js +171 -136
  3. package/package.json +6 -3
package/README.md CHANGED
@@ -1,8 +1,16 @@
1
1
  # howcode
2
2
 
3
- Launch the Howcode desktop app from npm.
3
+ Howcode is a desktop app for coding with Pi.
4
4
 
5
- ## Use
5
+ It gives you:
6
+
7
+ - threaded Pi chats tied to your projects
8
+ - a built-in terminal
9
+ - project and inbox sidebars
10
+ - git and diff workflows in the app, with some early-release actions still partial
11
+ - local desktop performance instead of a browser tab
12
+
13
+ ## Install / run
6
14
 
7
15
  ```bash
8
16
  npx howcode
@@ -11,17 +19,32 @@ npm i -g howcode
11
19
  howcode
12
20
  ```
13
21
 
14
- On first run, the launcher downloads the matching desktop build from GitHub Releases and caches it locally.
22
+ This npm package is a small launcher.
15
23
 
16
- ## Linux note
24
+ On first run, it downloads the matching desktop app for your platform from GitHub Releases and caches it locally.
25
+ After the first successful download, it can fall back to the cached app if release metadata is temporarily unavailable.
17
26
 
18
- If the Linux build hits a WebKit/GBM white-screen issue, the launcher retries with `WEBKIT_DISABLE_DMABUF_RENDERER=1` automatically.
27
+ On Windows, the first successful run also creates a Start Menu shortcut for `howcode`, so you do
28
+ not need to find the cached executable or add the cache directory to `PATH`.
19
29
 
20
- If you are launching a downloaded Linux release asset manually, use:
30
+ ## What you actually get
21
31
 
22
- ```bash
23
- WEBKIT_DISABLE_DMABUF_RENDERER=1 ./howcode/bin/launcher
24
- ```
32
+ - macOS, Linux, and Windows desktop builds
33
+ - Windows installer artifacts in GitHub Releases
34
+ - Linux AppImage artifacts for direct installs
35
+ - local cached installs after first download
36
+ - desktop builds that bundle Electron/Chromium for a more consistent renderer
37
+
38
+ ## Project
39
+
40
+ - App repo: https://github.com/IgorWarzocha/howcode
41
+ - Issues: https://github.com/IgorWarzocha/howcode/issues
42
+
43
+ ## Renderer note
44
+
45
+ Release builds now bundle Electron/Chromium on macOS, Linux, and Windows. The launcher no longer needs to inject the old Linux `WEBKIT_DISABLE_DMABUF_RENDERER` workaround.
46
+
47
+ Expect downloads to be larger than the native-webview builds in exchange for more consistent rendering behavior.
25
48
 
26
49
  ## Cache location
27
50
 
package/lib/howcode.js CHANGED
@@ -1,51 +1,52 @@
1
1
  const fs = require("node:fs");
2
2
  const fsp = require("node:fs/promises");
3
+ const crypto = require("node:crypto");
3
4
  const os = require("node:os");
4
5
  const path = require("node:path");
5
- const { spawn, spawnSync } = require("node:child_process");
6
+ const { spawn } = require("node:child_process");
6
7
  const { pipeline } = require("node:stream/promises");
7
8
  const { Readable } = require("node:stream");
9
+ const tar = require("tar");
8
10
 
9
11
  const packageJson = require("../package.json");
10
12
 
11
13
  const APP_NAME = packageJson.howcode.appName;
12
14
  const RELEASE_BASE_URL = process.env.HOWCODE_BASE_URL || packageJson.howcode.releaseBaseUrl;
15
+ const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
13
16
 
14
17
  const TARGETS = {
15
18
  "darwin:arm64": {
16
19
  os: "macos",
17
20
  arch: "arm64",
18
- executable: `${APP_NAME}.app/Contents/MacOS/launcher`,
21
+ executable: `${APP_NAME}.app/Contents/MacOS/${APP_NAME}`,
19
22
  },
20
23
  "darwin:x64": {
21
24
  os: "macos",
22
25
  arch: "x64",
23
- executable: `${APP_NAME}.app/Contents/MacOS/launcher`,
26
+ executable: `${APP_NAME}.app/Contents/MacOS/${APP_NAME}`,
24
27
  },
25
28
  "linux:arm64": {
26
29
  os: "linux",
27
30
  arch: "arm64",
28
- executable: `${APP_NAME}/bin/launcher`,
31
+ executable: `${APP_NAME}/${APP_NAME}`,
29
32
  },
30
33
  "linux:x64": {
31
34
  os: "linux",
32
35
  arch: "x64",
33
- executable: `${APP_NAME}/bin/launcher`,
36
+ executable: `${APP_NAME}/${APP_NAME}`,
34
37
  },
35
38
  "win32:arm64": {
36
39
  os: "win",
37
- arch: "x64",
38
- executable: `${APP_NAME}/bin/launcher.exe`,
40
+ arch: "arm64",
41
+ executable: `${APP_NAME}/${APP_NAME}.exe`,
39
42
  },
40
43
  "win32:x64": {
41
44
  os: "win",
42
45
  arch: "x64",
43
- executable: `${APP_NAME}/bin/launcher.exe`,
46
+ executable: `${APP_NAME}/${APP_NAME}.exe`,
44
47
  },
45
48
  };
46
49
 
47
- const LINUX_DMABUF_FAILURE_PATTERNS = [/Failed to create GBM buffer/i, /GLXBadWindow/i, /dmabuf/i];
48
-
49
50
  function readJsonIfPresent(filePath) {
50
51
  try {
51
52
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
@@ -87,14 +88,134 @@ function getPaths(target, releaseInfo) {
87
88
  const versionsRoot = path.join(cacheRoot, "versions");
88
89
  const releaseKey = `${releaseInfo.version}-${releaseInfo.hash}`;
89
90
  const installDir = path.join(versionsRoot, releaseKey);
91
+ const launcherWorkingDirectory = path.dirname(path.join(installDir, target.executable));
90
92
  return {
91
93
  cacheRoot,
92
94
  currentFile: path.join(cacheRoot, "current.json"),
95
+ windowsCommandFile: path.join(cacheRoot, `${APP_NAME}.cmd`),
96
+ launcherWorkingDirectory,
93
97
  installDir,
94
98
  executablePath: path.join(installDir, target.executable),
95
99
  };
96
100
  }
97
101
 
102
+ function getWindowsStartMenuShortcutPath() {
103
+ const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
104
+ return path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", `${APP_NAME}.lnk`);
105
+ }
106
+
107
+ function escapeWindowsCommandValue(value) {
108
+ return value.replace(/%/g, "%%");
109
+ }
110
+
111
+ function getWindowsScriptHostPath(executableName) {
112
+ const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT;
113
+ if (systemRoot) {
114
+ return path.join(systemRoot, "System32", executableName);
115
+ }
116
+
117
+ return path.join("C:", "Windows", "System32", executableName);
118
+ }
119
+
120
+ async function writeWindowsCommandLauncher(paths) {
121
+ const commandContents = [
122
+ "@echo off",
123
+ "chcp 65001 >nul",
124
+ "setlocal",
125
+ "set NODE_TLS_REJECT_UNAUTHORIZED=",
126
+ `set \"HOWCODE_EXE=${escapeWindowsCommandValue(paths.executablePath)}\"`,
127
+ `set \"HOWCODE_REPO_ROOT=${escapeWindowsCommandValue(paths.launcherWorkingDirectory)}\"`,
128
+ 'if not exist "%HOWCODE_EXE%" (',
129
+ ` echo ${APP_NAME}: installed app executable was not found.`,
130
+ ` echo Run npx ${APP_NAME} to repair the local install.`,
131
+ " exit /b 1",
132
+ ")",
133
+ 'start "" /D "%HOWCODE_REPO_ROOT%" "%HOWCODE_EXE%"',
134
+ "endlocal",
135
+ "",
136
+ ].join("\r\n");
137
+
138
+ await fsp.writeFile(paths.windowsCommandFile, commandContents, "utf8");
139
+ }
140
+
141
+ async function createWindowsStartMenuShortcut(paths) {
142
+ const shortcutPath = getWindowsStartMenuShortcutPath();
143
+ const shortcutScriptPath = path.join(
144
+ paths.cacheRoot,
145
+ `.create-${APP_NAME}-shortcut-${process.pid}.js`,
146
+ );
147
+ await fsp.mkdir(path.dirname(shortcutPath), { recursive: true });
148
+ await fsp.writeFile(
149
+ shortcutScriptPath,
150
+ [
151
+ "var shell = WScript.CreateObject('WScript.Shell');",
152
+ "var shortcut = shell.CreateShortcut(WScript.Arguments.Item(0));",
153
+ "shortcut.TargetPath = WScript.Arguments.Item(1);",
154
+ "shortcut.WorkingDirectory = WScript.Arguments.Item(2);",
155
+ "shortcut.IconLocation = WScript.Arguments.Item(3);",
156
+ "shortcut.Description = WScript.Arguments.Item(4);",
157
+ "shortcut.Save();",
158
+ "",
159
+ ].join("\r\n"),
160
+ "utf8",
161
+ );
162
+
163
+ try {
164
+ await new Promise((resolve, reject) => {
165
+ const child = spawn(
166
+ getWindowsScriptHostPath("cscript.exe"),
167
+ [
168
+ "//NoLogo",
169
+ shortcutScriptPath,
170
+ shortcutPath,
171
+ paths.windowsCommandFile,
172
+ paths.launcherWorkingDirectory,
173
+ `${paths.executablePath},0`,
174
+ "howcode",
175
+ ],
176
+ { stdio: "ignore", windowsHide: true },
177
+ );
178
+ child.on("error", reject);
179
+ child.on("exit", (code) => {
180
+ if (code === 0) {
181
+ resolve();
182
+ } else {
183
+ reject(new Error(`cscript exited with code ${code} while creating Start Menu shortcut.`));
184
+ }
185
+ });
186
+ });
187
+ } finally {
188
+ await fsp.rm(shortcutScriptPath, { force: true });
189
+ }
190
+
191
+ return shortcutPath;
192
+ }
193
+
194
+ async function ensureWindowsLaunchIntegration(target, paths) {
195
+ if (target.os !== "win") {
196
+ return true;
197
+ }
198
+
199
+ try {
200
+ await writeWindowsCommandLauncher(paths);
201
+ } catch (error) {
202
+ const message = error instanceof Error ? error.message : String(error);
203
+ console.warn(`${APP_NAME}: could not create command launcher: ${message}`);
204
+ console.warn(`${APP_NAME}: Start Menu shortcut was not updated.`);
205
+ return false;
206
+ }
207
+
208
+ try {
209
+ await createWindowsStartMenuShortcut(paths);
210
+ return true;
211
+ } catch (error) {
212
+ const message = error instanceof Error ? error.message : String(error);
213
+ console.warn(`${APP_NAME}: could not create Start Menu shortcut: ${message}`);
214
+ console.warn(`${APP_NAME}: you can still relaunch with ${paths.windowsCommandFile}`);
215
+ return false;
216
+ }
217
+ }
218
+
98
219
  async function fetchJson(url) {
99
220
  const controller = new AbortController();
100
221
  const timeout = setTimeout(() => controller.abort(), 5000);
@@ -110,9 +231,9 @@ async function fetchJson(url) {
110
231
  }
111
232
  }
112
233
 
113
- async function downloadFile(url, filePath) {
234
+ async function downloadFile(url, filePath, timeoutMs = DOWNLOAD_TIMEOUT_MS) {
114
235
  const controller = new AbortController();
115
- const timeout = setTimeout(() => controller.abort(), 60_000);
236
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
116
237
 
117
238
  try {
118
239
  const response = await fetch(url, { signal: controller.signal });
@@ -127,6 +248,12 @@ async function downloadFile(url, filePath) {
127
248
  }
128
249
  }
129
250
 
251
+ async function sha256File(filePath) {
252
+ const hash = crypto.createHash("sha256");
253
+ await pipeline(fs.createReadStream(filePath), hash);
254
+ return hash.digest("hex");
255
+ }
256
+
130
257
  async function resolveLatestRelease(target) {
131
258
  const updateUrl = `${RELEASE_BASE_URL}/stable-${target.os}-${target.arch}-update.json`;
132
259
  const metadata = await fetchJson(updateUrl);
@@ -153,16 +280,19 @@ async function installRelease(target, releaseInfo, paths) {
153
280
  await fsp.mkdir(tempRoot, { recursive: true });
154
281
  await fsp.mkdir(path.dirname(paths.installDir), { recursive: true });
155
282
  await downloadFile(releaseInfo.assetUrl, archivePath);
156
- await fsp.mkdir(tempInstallDir, { recursive: true });
157
-
158
- const extract = spawnSync("tar", ["-xzf", archivePath, "-C", tempInstallDir], {
159
- stdio: "inherit",
160
- });
161
283
 
162
- if (extract.status !== 0) {
163
- throw new Error("Failed to extract downloaded archive with `tar -xzf`.");
284
+ const archiveHash = await sha256File(archivePath);
285
+ if (archiveHash !== releaseInfo.hash) {
286
+ await fsp.rm(tempRoot, { recursive: true, force: true });
287
+ throw new Error(
288
+ `Downloaded archive hash mismatch. Expected ${releaseInfo.hash}, got ${archiveHash}.`,
289
+ );
164
290
  }
165
291
 
292
+ await fsp.mkdir(tempInstallDir, { recursive: true });
293
+
294
+ await tar.x({ file: archivePath, cwd: tempInstallDir });
295
+
166
296
  if (!fs.existsSync(path.join(tempInstallDir, target.executable))) {
167
297
  throw new Error(`Downloaded archive did not contain ${target.executable}.`);
168
298
  }
@@ -206,134 +336,26 @@ async function pruneOldVersions(cacheRoot, keepDir) {
206
336
  }
207
337
 
208
338
  function spawnLauncherProcess(executablePath, options = {}) {
339
+ const env = {
340
+ ...process.env,
341
+ HOWCODE_REPO_ROOT: process.env.HOWCODE_REPO_ROOT || process.cwd(),
342
+ ...(options.env || {}),
343
+ };
344
+ Reflect.deleteProperty(env, "NODE_TLS_REJECT_UNAUTHORIZED");
345
+
209
346
  return spawn(executablePath, [], {
210
347
  detached: true,
211
348
  stdio: options.stdio || "ignore",
212
349
  windowsHide: true,
213
350
  cwd: path.dirname(executablePath),
214
- env: {
215
- ...process.env,
216
- ...(options.env || {}),
217
- },
351
+ env,
218
352
  });
219
353
  }
220
354
 
221
- function killDetachedProcess(child) {
222
- if (!child.pid) {
223
- return;
224
- }
225
-
226
- try {
227
- if (process.platform !== "win32") {
228
- process.kill(-child.pid, "SIGTERM");
229
- return;
230
- }
231
- } catch {}
232
-
233
- try {
234
- child.kill("SIGTERM");
235
- } catch {}
236
- }
237
-
238
- function hasLinuxDmabufFailure(output) {
239
- return LINUX_DMABUF_FAILURE_PATTERNS.some((pattern) => pattern.test(output));
240
- }
241
-
242
- function trimLauncherLog(output) {
243
- return output.trim().split(/\r?\n/).slice(-10).join("\n");
244
- }
245
-
246
355
  async function launch(executablePath) {
247
- if (process.platform !== "linux" || process.env.WEBKIT_DISABLE_DMABUF_RENDERER === "1") {
248
- const child = spawnLauncherProcess(executablePath);
249
- child.unref();
250
- return;
251
- }
252
-
253
- const child = spawnLauncherProcess(executablePath, {
254
- stdio: ["ignore", "pipe", "pipe"],
255
- });
256
-
257
- let output = "";
258
- const appendOutput = (chunk) => {
259
- output += chunk.toString();
260
- if (output.length > 32_000) {
261
- output = output.slice(-32_000);
262
- }
263
- };
264
-
265
- child.stdout?.on("data", appendOutput);
266
- child.stderr?.on("data", appendOutput);
267
-
268
- const outcome = await new Promise((resolve) => {
269
- let settled = false;
270
-
271
- const finish = (value) => {
272
- if (settled) {
273
- return;
274
- }
275
-
276
- settled = true;
277
- clearTimeout(timer);
278
- resolve(value);
279
- };
280
-
281
- const checkForFallback = () => {
282
- if (hasLinuxDmabufFailure(output)) {
283
- finish({ type: "fallback" });
284
- }
285
- };
356
+ const child = spawnLauncherProcess(executablePath);
286
357
 
287
- const timer = setTimeout(() => finish({ type: "ok" }), 4_000);
288
-
289
- child.stdout?.on("data", checkForFallback);
290
- child.stderr?.on("data", checkForFallback);
291
- child.once("error", (error) => finish({ type: "error", error }));
292
- child.once("exit", (code, signal) => {
293
- if (hasLinuxDmabufFailure(output)) {
294
- finish({ type: "fallback" });
295
- return;
296
- }
297
-
298
- finish({ type: "exit", code, signal });
299
- });
300
- });
301
-
302
- child.stdout?.destroy();
303
- child.stderr?.destroy();
304
358
  child.unref();
305
-
306
- if (outcome.type === "fallback") {
307
- killDetachedProcess(child);
308
-
309
- const fallbackChild = spawnLauncherProcess(executablePath, {
310
- env: {
311
- WEBKIT_DISABLE_DMABUF_RENDERER: "1",
312
- },
313
- });
314
-
315
- fallbackChild.unref();
316
- return;
317
- }
318
-
319
- if (outcome.type === "ok") {
320
- return;
321
- }
322
-
323
- if (outcome.type === "error") {
324
- throw outcome.error;
325
- }
326
-
327
- if (outcome.code === 0) {
328
- return;
329
- }
330
-
331
- const logTail = trimLauncherLog(output);
332
- throw new Error(
333
- logTail
334
- ? `Desktop launcher exited early.\n${logTail}`
335
- : `Desktop launcher exited early with code ${outcome.code ?? "unknown"}.`,
336
- );
337
359
  }
338
360
 
339
361
  async function main() {
@@ -348,6 +370,14 @@ async function main() {
348
370
  releaseInfo = await resolveLatestRelease(target);
349
371
  } catch (error) {
350
372
  if (current?.executablePath && fs.existsSync(current.executablePath)) {
373
+ await ensureWindowsLaunchIntegration(target, {
374
+ cacheRoot,
375
+ currentFile: path.join(cacheRoot, "current.json"),
376
+ windowsCommandFile: path.join(cacheRoot, `${APP_NAME}.cmd`),
377
+ installDir: current.installDir || path.dirname(path.dirname(current.executablePath)),
378
+ launcherWorkingDirectory: path.dirname(current.executablePath),
379
+ executablePath: current.executablePath,
380
+ });
351
381
  await launch(current.executablePath);
352
382
  return;
353
383
  }
@@ -356,10 +386,15 @@ async function main() {
356
386
  }
357
387
 
358
388
  const paths = getPaths(target, releaseInfo);
389
+ const didInstall = !fs.existsSync(paths.executablePath);
359
390
  if (!fs.existsSync(paths.executablePath)) {
360
391
  await installRelease(target, releaseInfo, paths);
361
392
  }
362
393
 
394
+ const launchIntegrationReady = await ensureWindowsLaunchIntegration(target, paths);
395
+ if (target.os === "win" && didInstall && launchIntegrationReady) {
396
+ console.log(`${APP_NAME}: installed. You can relaunch it from the Windows Start Menu.`);
397
+ }
363
398
  await pruneOldVersions(cacheRoot, paths.installDir);
364
399
  await launch(paths.executablePath);
365
400
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "howcode",
3
- "version": "0.1.1",
4
- "description": "Launch the Howcode desktop app from npm or npx.",
3
+ "version": "0.1.3",
4
+ "description": "Desktop coding app for Pi with projects, terminal, git, and diff workflows.",
5
5
  "license": "MIT",
6
6
  "author": "Igor Warzocha",
7
7
  "homepage": "https://github.com/IgorWarzocha/howcode",
@@ -16,10 +16,13 @@
16
16
  "howcode": "bin/howcode.js"
17
17
  },
18
18
  "files": ["bin", "lib", "README.md", "LICENSE"],
19
+ "dependencies": {
20
+ "tar": "^7.5.1"
21
+ },
19
22
  "engines": {
20
23
  "node": ">=18"
21
24
  },
22
- "keywords": ["desktop", "launcher", "pi", "coding"],
25
+ "keywords": ["desktop", "coding", "ai", "terminal", "git", "diff", "assistant"],
23
26
  "howcode": {
24
27
  "appName": "howcode",
25
28
  "releaseBaseUrl": "https://github.com/IgorWarzocha/howcode/releases/latest/download"