create-walle 0.9.24 → 0.9.26
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/README.md +8 -0
- package/bin/create-walle.js +815 -45
- package/package.json +2 -2
- package/template/bin/ctm-dev-cleanup.js +90 -4
- package/template/bin/ctm-launch.sh +49 -1
- package/template/bin/dev.sh +45 -1
- package/template/bin/ensure-stable-node.js +132 -0
- package/template/bin/install-service.sh +9 -0
- package/template/claude-task-manager/api-prompts.js +899 -119
- package/template/claude-task-manager/approval-agent.js +360 -40
- package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
- package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
- package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
- package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
- package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
- package/template/claude-task-manager/db.js +399 -48
- package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
- package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
- package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
- package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
- package/template/claude-task-manager/lib/approval-hook.js +200 -0
- package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
- package/template/claude-task-manager/lib/auth-rules.js +11 -0
- package/template/claude-task-manager/lib/background-llm.js +32 -4
- package/template/claude-task-manager/lib/codesign-identity.js +140 -0
- package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
- package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
- package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
- package/template/claude-task-manager/lib/codex-paths.js +73 -0
- package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
- package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
- package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
- package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
- package/template/claude-task-manager/lib/command-targets.js +163 -0
- package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
- package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
- package/template/claude-task-manager/lib/escalation-review.js +80 -3
- package/template/claude-task-manager/lib/flow-control.js +52 -0
- package/template/claude-task-manager/lib/fs-watcher.js +24 -15
- package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
- package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
- package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
- package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
- package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
- package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
- package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
- package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
- package/template/claude-task-manager/lib/perf-tracker.js +29 -2
- package/template/claude-task-manager/lib/permission-match.js +146 -16
- package/template/claude-task-manager/lib/project-slug.js +33 -0
- package/template/claude-task-manager/lib/prompt-intent.js +51 -4
- package/template/claude-task-manager/lib/read-pool-client.js +48 -3
- package/template/claude-task-manager/lib/real-node.js +73 -0
- package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
- package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
- package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
- package/template/claude-task-manager/lib/session-history.js +5 -7
- package/template/claude-task-manager/lib/session-host-manager.js +19 -0
- package/template/claude-task-manager/lib/session-jobs.js +6 -0
- package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
- package/template/claude-task-manager/lib/session-messages-page.js +211 -0
- package/template/claude-task-manager/lib/session-messages-projection.js +170 -0
- package/template/claude-task-manager/lib/session-standup.js +8 -0
- package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
- package/template/claude-task-manager/lib/session-token-usage.js +30 -8
- package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
- package/template/claude-task-manager/lib/storage-migration.js +2 -1
- package/template/claude-task-manager/lib/transcript-store.js +179 -12
- package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
- package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
- package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
- package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
- package/template/claude-task-manager/package.json +5 -2
- package/template/claude-task-manager/prompt-harvest.js +31 -11
- package/template/claude-task-manager/providers/claude-code.js +29 -1
- package/template/claude-task-manager/providers/codex.js +13 -1
- package/template/claude-task-manager/public/css/setup.css +11 -0
- package/template/claude-task-manager/public/css/walle-session.css +132 -4
- package/template/claude-task-manager/public/css/walle.css +89 -0
- package/template/claude-task-manager/public/icon-16.png +0 -0
- package/template/claude-task-manager/public/icon-32.png +0 -0
- package/template/claude-task-manager/public/icon-512.png +0 -0
- package/template/claude-task-manager/public/index.html +2483 -165
- package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
- package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
- package/template/claude-task-manager/public/js/message-renderer.js +60 -1
- package/template/claude-task-manager/public/js/prompts.js +13 -1
- package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
- package/template/claude-task-manager/public/js/setup.js +54 -10
- package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
- package/template/claude-task-manager/public/js/stream-view.js +78 -0
- package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
- package/template/claude-task-manager/public/js/tool-state.js +155 -0
- package/template/claude-task-manager/public/js/walle-session.js +887 -326
- package/template/claude-task-manager/public/js/walle.js +306 -195
- package/template/claude-task-manager/public/m/app.css +1 -0
- package/template/claude-task-manager/public/m/app.js +33 -3
- package/template/claude-task-manager/queue-engine.js +45 -1
- package/template/claude-task-manager/server.js +3367 -540
- package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
- package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
- package/template/claude-task-manager/workers/read-pool-worker.js +92 -5
- package/template/claude-task-manager/workers/session-host-process.js +10 -0
- package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
- package/template/package.json +2 -3
- package/template/shared/icons/AppIcon-ctm.icns +0 -0
- package/template/shared/icons/AppIcon-walle.icns +0 -0
- package/template/wall-e/agent.js +139 -18
- package/template/wall-e/api-walle.js +201 -22
- package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
- package/template/wall-e/brain.js +1053 -43
- package/template/wall-e/chat.js +427 -86
- package/template/wall-e/coding/acceptance-contract.js +26 -1
- package/template/wall-e/coding/action-memory-policy.js +353 -0
- package/template/wall-e/coding/action-memory-store.js +814 -0
- package/template/wall-e/coding/initial-messages.js +197 -0
- package/template/wall-e/coding/no-progress-guard.js +327 -0
- package/template/wall-e/coding/permission-service.js +88 -22
- package/template/wall-e/coding/session-workspaces.js +81 -0
- package/template/wall-e/coding/shell-sandbox.js +124 -0
- package/template/wall-e/coding/stream-processor.js +63 -2
- package/template/wall-e/coding/tool-execution-controller.js +14 -1
- package/template/wall-e/coding/tool-registry.js +1 -1
- package/template/wall-e/coding/transcript-writer.js +3 -0
- package/template/wall-e/coding-orchestrator.js +636 -35
- package/template/wall-e/coding-prompts.js +51 -2
- package/template/wall-e/docs/model-routing-policy.md +59 -0
- package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
- package/template/wall-e/extraction/knowledge-extractor.js +76 -23
- package/template/wall-e/http/chat-api.js +30 -12
- package/template/wall-e/http/model-admin.js +93 -1
- package/template/wall-e/lib/background-lanes.js +133 -0
- package/template/wall-e/lib/boot-profile.js +11 -0
- package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
- package/template/wall-e/lib/brain-read-pool-client.js +311 -0
- package/template/wall-e/lib/diagnostics-flags.js +87 -0
- package/template/wall-e/lib/event-loop-monitor.js +74 -3
- package/template/wall-e/lib/mcp-integration.js +7 -1
- package/template/wall-e/lib/real-node.js +98 -0
- package/template/wall-e/lib/runtime-health.js +206 -0
- package/template/wall-e/lib/runtime-worker-pool.js +101 -0
- package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
- package/template/wall-e/lib/scheduler.js +446 -17
- package/template/wall-e/lib/service-health.js +61 -2
- package/template/wall-e/lib/service-readiness.js +258 -0
- package/template/wall-e/lib/usage.js +152 -0
- package/template/wall-e/lib/worker-thread-pool.js +389 -0
- package/template/wall-e/llm/client.js +81 -4
- package/template/wall-e/llm/default-fallback.js +54 -8
- package/template/wall-e/llm/mlx.js +536 -73
- package/template/wall-e/llm/mlx.plugin.json +1 -1
- package/template/wall-e/llm/ollama.js +342 -43
- package/template/wall-e/llm/provider-error.js +18 -1
- package/template/wall-e/llm/provider-health-state.js +176 -0
- package/template/wall-e/llm/routing-policy.js +796 -0
- package/template/wall-e/llm/supported-models.js +5 -0
- package/template/wall-e/loops/tasks.js +60 -14
- package/template/wall-e/loops/think.js +89 -24
- package/template/wall-e/mcp-server.js +192 -28
- package/template/wall-e/server.js +32 -7
- package/template/wall-e/shared/sqlite-owner-guard.js +30 -0
- package/template/wall-e/shared/sqlite-owner-write-queue.js +225 -0
- package/template/wall-e/shared/sqlite-storage-policy.js +111 -0
- package/template/wall-e/shared/sqlite-write-lock.js +428 -0
- package/template/wall-e/skills/script-skill-runner.js +8 -1
- package/template/wall-e/skills/skill-planner.js +64 -1
- package/template/wall-e/tools/builtin-middleware.js +67 -2
- package/template/wall-e/tools/local-tools.js +116 -26
- package/template/wall-e/tools/permission-checker.js +52 -4
- package/template/wall-e/tools/permission-rules.js +36 -0
- package/template/wall-e/tools/shell-analyzer.js +46 -1
- package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
- package/template/wall-e/training/real-trajectory-miner.js +2617 -0
- package/template/wall-e/training/replay-eval-analysis.js +151 -0
- package/template/wall-e/training/run-shell-command-selector.js +277 -0
- package/template/wall-e/training/tool-sft-dataset.js +312 -0
- package/template/wall-e/training/tool-sft-renderers.js +144 -0
- package/template/wall-e/training/tool-trace-harvester.js +1440 -0
- package/template/wall-e/training/trajectory-action-selector.js +364 -0
- package/template/wall-e/weather-runtime.js +232 -0
- package/template/wall-e/workers/brain-owner-worker.js +162 -0
- package/template/wall-e/workers/brain-read-worker.js +148 -0
- package/template/wall-e/workers/runtime-worker.js +145 -0
package/bin/create-walle.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const { execFileSync, spawn } = require('child_process');
|
|
4
|
+
const { execFileSync, spawn, spawnSync } = require('child_process');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
7
8
|
|
|
8
9
|
const BOLD = '\x1b[1m';
|
|
9
10
|
const DIM = '\x1b[2m';
|
|
@@ -27,9 +28,216 @@ const NATIVE_DEPENDENCIES = new Set([
|
|
|
27
28
|
'tree-sitter-bash',
|
|
28
29
|
]);
|
|
29
30
|
|
|
31
|
+
// The pinned Node runtime the macOS daemon adopts: an official, Apple-notarized build
|
|
32
|
+
// (signed by the Node.js Foundation) downloaded once from nodejs.org and cached under
|
|
33
|
+
// ~/.walle/notarized-node. Running the launchd daemon under a notarized identity is what lets
|
|
34
|
+
// a background macOS process obtain Screen Recording (and other TCC) grants — a self-signed or
|
|
35
|
+
// cloned node cannot. The whole runtime tree (CTM server, Wall-E supervisor, session-host
|
|
36
|
+
// forks) inherits this node via process.execPath, so native modules are built against THIS
|
|
37
|
+
// version's ABI (see daemonNodeForBuild / npmInstall). Keep in sync with the vendored Node in
|
|
38
|
+
// create-walle/macos/build-macos-app.sh (NODE_VERSION). Set WALLE_NO_NOTARIZED_NODE=1 to opt out.
|
|
39
|
+
const NOTARIZED_NODE_VERSION = '25.2.1';
|
|
40
|
+
|
|
30
41
|
// Files to preserve during update (user config, not code)
|
|
31
42
|
const PRESERVE_ON_UPDATE = ['.env', 'wall-e/wall-e-config.json'];
|
|
32
43
|
|
|
44
|
+
// ── macOS .app bundle + self-signed signing (Activity Monitor icon/name + stable TCC) ──
|
|
45
|
+
// macOS shows a process's icon from the .app bundle that encloses its executable, so we
|
|
46
|
+
// place the node binary inside per-daemon LSUIElement bundles. Self-signing with a stable
|
|
47
|
+
// cert gives a stable codesign designated requirement → TCC grants (hotkey) persist across
|
|
48
|
+
// updates. All best-effort: never block install; failures degrade to unsigned/iconless.
|
|
49
|
+
const BUNDLE_ROOT = process.env.WALLE_BUNDLE_DIR || path.join(process.env.HOME, '.walle', 'bundles');
|
|
50
|
+
const SIGN_IDENTITY_CN = 'Wall-E Local Signing';
|
|
51
|
+
const SIGN_KEYCHAIN = process.env.WALLE_SIGN_KEYCHAIN || path.join(process.env.HOME, '.walle', 'walle-signing.keychain-db');
|
|
52
|
+
const APP_BUNDLES = [
|
|
53
|
+
{ app: 'Coding Task Manager.app', exec: 'Coding Task Manager', name: 'Coding Task Manager', bundleId: 'com.walle.ctm', icns: 'AppIcon-ctm.icns' },
|
|
54
|
+
{ app: 'Wall-E.app', exec: 'Wall-E', name: 'Wall-E', bundleId: 'com.walle.agent', icns: 'AppIcon-walle.icns' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function ctmBundleExec() {
|
|
58
|
+
return path.join(BUNDLE_ROOT, 'Coding Task Manager.app', 'Contents', 'MacOS', 'Coding Task Manager');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function walleBundleExec() {
|
|
62
|
+
return path.join(BUNDLE_ROOT, 'Wall-E.app', 'Contents', 'MacOS', 'Wall-E');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The code-signing Team Identifier of a binary, or '' when unsigned/self-signed. A NON-empty
|
|
66
|
+
// value means the binary carries a stable, OS-trusted Designated Requirement (Developer-ID or
|
|
67
|
+
// notarized) — its TCC grants persist AND macOS keeps showing the .app's CFBundleName in prompts.
|
|
68
|
+
function execTeamIdentifier(binaryPath) {
|
|
69
|
+
if (process.platform !== 'darwin' || !binaryPath) return '';
|
|
70
|
+
try {
|
|
71
|
+
if (!fs.existsSync(binaryPath)) return '';
|
|
72
|
+
// `codesign -dv` writes its report to STDERR and exits 0, so read both streams via spawnSync.
|
|
73
|
+
const r = spawnSync('codesign', ['-dv', '--verbose=4', binaryPath], { encoding: 'utf8' });
|
|
74
|
+
const text = `${r.stdout || ''}\n${r.stderr || ''}`;
|
|
75
|
+
const m = text.match(/^TeamIdentifier=(.+)$/m);
|
|
76
|
+
const team = m ? m[1].trim() : '';
|
|
77
|
+
return team === 'not set' ? '' : team;
|
|
78
|
+
} catch {
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Which node binary should launch the CTM daemon. We want an identity that is BOTH stable (its
|
|
84
|
+
// TCC grants persist across restarts) AND branded (macOS shows "Coding Task Manager"/"Wall-E" in
|
|
85
|
+
// prompts, not the anonymous "node"). A Developer-ID-signed .app bundle exec is the only thing
|
|
86
|
+
// that is both; a bare notarized node is stable but anonymous. Order:
|
|
87
|
+
// 1. WALLE_NOTARIZED_NODE — set by the downloadable Developer-ID Wall-E.app (runs under the
|
|
88
|
+
// app's own branded launcher identity, so it shows "Wall-E").
|
|
89
|
+
// 2. The CTM .app bundle exec WHEN it carries a stable Team Identifier (Developer-ID-signed by
|
|
90
|
+
// bin/ensure-stable-node.js) — branded AND grant-persisting.
|
|
91
|
+
// 3. The official notarized node we downloaded ourselves — stable but anonymous ("node"); the
|
|
92
|
+
// fallback for machines without a Developer ID, where a branded-stable bundle isn't possible.
|
|
93
|
+
// 4. The self-signed CTM bundle exec — branded but re-prompts (no stable Team ID).
|
|
94
|
+
// 5. The running node.
|
|
95
|
+
function daemonExec() {
|
|
96
|
+
const notarized = process.env.WALLE_NOTARIZED_NODE;
|
|
97
|
+
if (notarized && process.platform === 'darwin') {
|
|
98
|
+
try { if (fs.existsSync(notarized)) return notarized; } catch {}
|
|
99
|
+
}
|
|
100
|
+
if (process.platform === 'darwin') {
|
|
101
|
+
const bundle = ctmBundleExec();
|
|
102
|
+
let bundleExists = false;
|
|
103
|
+
try { bundleExists = fs.existsSync(bundle); } catch {}
|
|
104
|
+
// Prefer the branded bundle only when it is Developer-ID-signed (has a Team ID): branded AND
|
|
105
|
+
// its grants persist. Otherwise prefer the notarized bare node (stable but "node").
|
|
106
|
+
if (bundleExists && execTeamIdentifier(bundle)) return bundle;
|
|
107
|
+
const own = validatedNotarizedNode();
|
|
108
|
+
if (own) return own;
|
|
109
|
+
if (bundleExists) return bundle; // self-signed branded bundle — branded name, but re-prompts
|
|
110
|
+
}
|
|
111
|
+
return process.execPath;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Notarized daemon node (npx parity with the downloadable app's TCC fix) ──
|
|
115
|
+
// The downloadable app ships its own notarized node; for the `npx create-walle` path we fetch
|
|
116
|
+
// the equivalent official notarized Node once and point the daemon at it. All best-effort:
|
|
117
|
+
// any failure leaves the daemon on its default node — never blocks install.
|
|
118
|
+
|
|
119
|
+
function notarizedNodeDir() {
|
|
120
|
+
return process.env.WALLE_NOTARIZED_NODE_DIR || path.join(process.env.HOME, '.walle', 'notarized-node');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function notarizedNodePath() {
|
|
124
|
+
return path.join(notarizedNodeDir(), 'bin', 'node');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Returns the cached notarized node ONLY when the post-verify marker matches the pinned
|
|
128
|
+
// version (the marker is written last, so a partial/failed download is never trusted).
|
|
129
|
+
function validatedNotarizedNode() {
|
|
130
|
+
if (process.platform !== 'darwin') return null;
|
|
131
|
+
if (process.env.WALLE_NO_NOTARIZED_NODE === '1') return null;
|
|
132
|
+
const dest = notarizedNodePath();
|
|
133
|
+
const marker = path.join(notarizedNodeDir(), 'version');
|
|
134
|
+
try {
|
|
135
|
+
if (fs.existsSync(dest) && fs.existsSync(marker) &&
|
|
136
|
+
fs.readFileSync(marker, 'utf8').trim() === NOTARIZED_NODE_VERSION) {
|
|
137
|
+
return dest;
|
|
138
|
+
}
|
|
139
|
+
} catch {}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The node whose ABI native modules must target = whatever the daemon will run under.
|
|
144
|
+
function daemonNodeForBuild() {
|
|
145
|
+
if (process.platform !== 'darwin') return process.execPath;
|
|
146
|
+
return validatedNotarizedNode() || process.execPath;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function nodeReportsVersion(bin, version) {
|
|
150
|
+
try {
|
|
151
|
+
return execFileSync(bin, ['-v'], { encoding: 'utf8', timeout: 10000 }).trim() === `v${version}`;
|
|
152
|
+
} catch { return false; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Integrity check: codesign --verify catches a tampered/altered binary (gates adoption).
|
|
156
|
+
// spctl reports the Gatekeeper/notarization assessment (informational only — a validly-signed
|
|
157
|
+
// official node is still safe to RUN even if spctl is quirky for a bare CLI binary; the TCC
|
|
158
|
+
// benefit only adds to today's behavior, it never regresses it).
|
|
159
|
+
function verifyNotarizedNode(bin, { log } = {}) {
|
|
160
|
+
try {
|
|
161
|
+
execFileSync('codesign', ['--verify', '--strict', bin], { stdio: 'pipe', timeout: 30000 });
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
if (log) {
|
|
166
|
+
let assessment;
|
|
167
|
+
try {
|
|
168
|
+
execFileSync('spctl', ['-a', '-vv', '--type', 'exec', bin], { stdio: 'pipe', timeout: 30000 });
|
|
169
|
+
assessment = 'accepted (notarized)';
|
|
170
|
+
} catch { assessment = 'signature valid (spctl unconfirmed)'; }
|
|
171
|
+
log(` ${DIM}node signature: ${assessment}${RESET}`);
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Download (once) the pinned official notarized Node + npm and cache it. Returns the node path
|
|
177
|
+
// on success, else null (install proceeds on the default node). npm is bundled alongside so
|
|
178
|
+
// native modules can be (re)built under this exact node — guaranteeing the daemon's runtime
|
|
179
|
+
// and its node_modules share one ABI.
|
|
180
|
+
function ensureNotarizedDaemonNode({ log = console.log } = {}) {
|
|
181
|
+
if (process.platform !== 'darwin') return null;
|
|
182
|
+
if (process.env.WALLE_NO_NOTARIZED_NODE === '1') return null;
|
|
183
|
+
const arch = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null;
|
|
184
|
+
if (!arch) return null;
|
|
185
|
+
|
|
186
|
+
const dir = notarizedNodeDir();
|
|
187
|
+
const dest = notarizedNodePath();
|
|
188
|
+
const marker = path.join(dir, 'version');
|
|
189
|
+
|
|
190
|
+
// Cached and still valid → reuse (the "download once").
|
|
191
|
+
const cached = validatedNotarizedNode();
|
|
192
|
+
if (cached && nodeReportsVersion(cached, NOTARIZED_NODE_VERSION) && verifyNotarizedNode(cached)) {
|
|
193
|
+
return cached;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let tmp;
|
|
197
|
+
try {
|
|
198
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
199
|
+
const base = `node-v${NOTARIZED_NODE_VERSION}-darwin-${arch}`;
|
|
200
|
+
const tarball = `${base}.tar.gz`;
|
|
201
|
+
const url = `https://nodejs.org/dist/v${NOTARIZED_NODE_VERSION}/${tarball}`;
|
|
202
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'walle-nnode-'));
|
|
203
|
+
const tgz = path.join(tmp, tarball);
|
|
204
|
+
log(` Downloading notarized Node ${NOTARIZED_NODE_VERSION} (${arch})…`);
|
|
205
|
+
execFileSync('curl', ['-fsSL', '-o', tgz, url], { timeout: 300000, stdio: 'pipe' });
|
|
206
|
+
// Extract just the node binary + bundled npm, stripping the version-named top dir.
|
|
207
|
+
execFileSync('tar', [
|
|
208
|
+
'-xzf', tgz, '-C', tmp, '--strip-components=1',
|
|
209
|
+
`${base}/bin/node`, `${base}/lib/node_modules/npm`,
|
|
210
|
+
], { timeout: 120000, stdio: 'pipe' });
|
|
211
|
+
|
|
212
|
+
fs.rmSync(path.join(dir, 'bin'), { recursive: true, force: true });
|
|
213
|
+
fs.rmSync(path.join(dir, 'lib'), { recursive: true, force: true });
|
|
214
|
+
fs.rmSync(marker, { force: true });
|
|
215
|
+
fs.cpSync(path.join(tmp, 'bin'), path.join(dir, 'bin'), { recursive: true });
|
|
216
|
+
fs.cpSync(path.join(tmp, 'lib'), path.join(dir, 'lib'), { recursive: true });
|
|
217
|
+
fs.chmodSync(dest, 0o755);
|
|
218
|
+
|
|
219
|
+
if (!nodeReportsVersion(dest, NOTARIZED_NODE_VERSION)) throw new Error('version check failed');
|
|
220
|
+
if (!verifyNotarizedNode(dest, { log })) throw new Error('signature verification failed');
|
|
221
|
+
|
|
222
|
+
fs.writeFileSync(marker, `${NOTARIZED_NODE_VERSION}\n`);
|
|
223
|
+
log(` ${GREEN}Notarized Node ready${RESET} ${DIM}(${dest})${RESET}`);
|
|
224
|
+
return dest;
|
|
225
|
+
} catch (e) {
|
|
226
|
+
log(` ${DIM}Notarized node unavailable (${e && e.message ? e.message : e}) — daemon will use the default node${RESET}`);
|
|
227
|
+
disableNotarizedNode();
|
|
228
|
+
return null;
|
|
229
|
+
} finally {
|
|
230
|
+
if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Drop the marker (and binary) so daemonExec/daemonNodeForBuild stop trusting it. Used when the
|
|
235
|
+
// download fails OR when we can't build native modules under it (avoids an ABI split).
|
|
236
|
+
function disableNotarizedNode() {
|
|
237
|
+
try { fs.rmSync(path.join(notarizedNodeDir(), 'version'), { force: true }); } catch {}
|
|
238
|
+
try { fs.rmSync(notarizedNodePath(), { force: true }); } catch {}
|
|
239
|
+
}
|
|
240
|
+
|
|
33
241
|
function writeCliLifecycleEvent(event, meta = {}) {
|
|
34
242
|
if (process.env.WALLE_TELEMETRY === '0' || process.env.WALLE_TELEMETRY === 'false') return;
|
|
35
243
|
try {
|
|
@@ -178,10 +386,16 @@ function install(targetDir) {
|
|
|
178
386
|
console.log(`\n Copying files...`);
|
|
179
387
|
copyRecursive(TEMPLATE_DIR, targetDir);
|
|
180
388
|
|
|
389
|
+
// Fetch the notarized daemon node BEFORE installing deps, so native modules build against
|
|
390
|
+
// its ABI (the daemon and its forks will all run under it). Best-effort / macOS-only.
|
|
391
|
+
ensureNotarizedDaemonNode();
|
|
392
|
+
|
|
181
393
|
console.log(` Installing dependencies...\n`);
|
|
182
394
|
npmInstall(targetDir);
|
|
183
395
|
|
|
184
396
|
compileHotkeyDaemon(targetDir);
|
|
397
|
+
compileScreenAuthHelper(targetDir);
|
|
398
|
+
compileDisclaimHelper(targetDir);
|
|
185
399
|
|
|
186
400
|
// .env
|
|
187
401
|
const envLines = [
|
|
@@ -223,6 +437,10 @@ function install(targetDir) {
|
|
|
223
437
|
|
|
224
438
|
saveWalleDir(path.resolve(targetDir));
|
|
225
439
|
|
|
440
|
+
// Build branded .app bundles (icon + name in Activity Monitor) before starting,
|
|
441
|
+
// so the launchd plist / background spawn can point at the bundle's node exec.
|
|
442
|
+
buildAppBundles(path.resolve(targetDir));
|
|
443
|
+
|
|
226
444
|
// Start the service
|
|
227
445
|
console.log(` Starting Wall-E...`);
|
|
228
446
|
startForegroundOrService(path.resolve(targetDir), port);
|
|
@@ -288,13 +506,21 @@ function update() {
|
|
|
288
506
|
// 5. Stamp version
|
|
289
507
|
stampVersion(dir);
|
|
290
508
|
|
|
291
|
-
// 6. Reinstall deps (in case package.json changed)
|
|
509
|
+
// 6. Reinstall deps (in case package.json changed). Refresh the notarized daemon node first
|
|
510
|
+
// so native modules rebuild against its ABI (idempotent: a valid cache is reused, not re-DLed).
|
|
511
|
+
ensureNotarizedDaemonNode();
|
|
292
512
|
console.log(` Installing dependencies...\n`);
|
|
293
513
|
npmInstall(dir);
|
|
294
514
|
|
|
295
515
|
compileHotkeyDaemon(dir);
|
|
516
|
+
compileScreenAuthHelper(dir);
|
|
517
|
+
compileDisclaimHelper(dir);
|
|
518
|
+
|
|
519
|
+
// 7. Refresh branded .app bundles (re-clone node to match the freshly built native
|
|
520
|
+
// modules' ABI, re-sign) then start again.
|
|
521
|
+
buildAppBundles(dir);
|
|
296
522
|
|
|
297
|
-
//
|
|
523
|
+
// 8. Start again
|
|
298
524
|
console.log(`\n Starting Wall-E...`);
|
|
299
525
|
startForegroundOrService(dir, port);
|
|
300
526
|
|
|
@@ -353,6 +579,12 @@ function status() {
|
|
|
353
579
|
}
|
|
354
580
|
|
|
355
581
|
function logs() {
|
|
582
|
+
// On Linux with systemd, the unit logs to the journal.
|
|
583
|
+
if (process.platform === 'linux' && hasSystemdUser() && fs.existsSync(path.join(process.env.HOME, '.config', 'systemd', 'user', 'walle.service'))) {
|
|
584
|
+
const child = spawn('journalctl', ['--user', '-u', 'walle.service', '-f'], { stdio: 'inherit' });
|
|
585
|
+
child.on('error', () => console.log(' Could not read the journal; try ~/.walle/logs/'));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
356
588
|
const logFile = path.join(process.env.HOME, '.walle', 'logs', 'walle.log');
|
|
357
589
|
if (!fs.existsSync(logFile)) {
|
|
358
590
|
console.log(`\n No logs yet at ${logFile}\n`);
|
|
@@ -363,9 +595,25 @@ function logs() {
|
|
|
363
595
|
}
|
|
364
596
|
|
|
365
597
|
function uninstall() {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
598
|
+
if (process.platform === 'darwin') {
|
|
599
|
+
const plist = path.join(process.env.HOME, 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
600
|
+
try { execFileSync('launchctl', ['unload', plist], { stdio: 'ignore' }); } catch {}
|
|
601
|
+
try { fs.unlinkSync(plist); } catch {}
|
|
602
|
+
// Branded .app bundles + Launchpad launcher.
|
|
603
|
+
try { fs.rmSync(BUNDLE_ROOT, { recursive: true, force: true }); } catch {}
|
|
604
|
+
try { fs.rmSync(path.join(process.env.HOME, 'Applications', 'Wall-E.app'), { recursive: true, force: true }); } catch {}
|
|
605
|
+
} else if (process.platform === 'linux') {
|
|
606
|
+
if (hasSystemdUser()) {
|
|
607
|
+
try { execFileSync('systemctl', ['--user', 'disable', '--now', 'walle.service'], { stdio: 'ignore' }); } catch {}
|
|
608
|
+
}
|
|
609
|
+
try { fs.unlinkSync(path.join(process.env.HOME, '.config', 'systemd', 'user', 'walle.service')); } catch {}
|
|
610
|
+
try { execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' }); } catch {}
|
|
611
|
+
try { fs.unlinkSync(path.join(process.env.HOME, '.local', 'share', 'applications', 'walle.desktop')); } catch {}
|
|
612
|
+
const themeDir = path.join(process.env.HOME, '.local', 'share', 'icons', 'hicolor');
|
|
613
|
+
try { fs.unlinkSync(path.join(themeDir, 'scalable', 'apps', 'walle.svg')); } catch {}
|
|
614
|
+
for (const s of ['16x16', '32x32', '512x512']) { try { fs.unlinkSync(path.join(themeDir, s, 'apps', 'walle.png')); } catch {} }
|
|
615
|
+
try { execFileSync('gtk-update-icon-cache', ['-f', '-t', themeDir], { stdio: 'ignore' }); } catch {}
|
|
616
|
+
}
|
|
369
617
|
try { fs.unlinkSync(INSTALL_PATH_FILE); } catch {}
|
|
370
618
|
console.log(`\n ${GREEN}Service uninstalled.${RESET} Your data in ~/.walle/data is preserved.\n`);
|
|
371
619
|
}
|
|
@@ -378,6 +626,10 @@ function stopQuiet(dir, port) {
|
|
|
378
626
|
if (process.platform === 'darwin' && fs.existsSync(plist)) {
|
|
379
627
|
try { execFileSync('launchctl', ['unload', plist], { stdio: 'ignore' }); } catch {}
|
|
380
628
|
}
|
|
629
|
+
// Linux: stop the systemd user unit so it doesn't auto-restart during update.
|
|
630
|
+
if (process.platform === 'linux' && hasSystemdUser() && fs.existsSync(path.join(process.env.HOME, '.config', 'systemd', 'user', 'walle.service'))) {
|
|
631
|
+
try { execFileSync('systemctl', ['--user', 'stop', 'walle.service'], { stdio: 'ignore' }); } catch {}
|
|
632
|
+
}
|
|
381
633
|
// Kill CTM and Wall-E processes
|
|
382
634
|
const wallePort = dir ? readWallePort(dir) : String(parseInt(port) + 1);
|
|
383
635
|
for (const p of [port, wallePort]) {
|
|
@@ -409,7 +661,9 @@ function startForegroundOrService(dir, port) {
|
|
|
409
661
|
// Start in background without launchd
|
|
410
662
|
const logDir = path.join(process.env.HOME, '.walle', 'logs');
|
|
411
663
|
fs.mkdirSync(logDir, { recursive: true });
|
|
412
|
-
|
|
664
|
+
// Launch via the notarized app node / CTM .app bundle exec when present (TCC + icon).
|
|
665
|
+
const ctmExec = daemonExec();
|
|
666
|
+
const child = spawn(ctmExec, ['claude-task-manager/server.js'], {
|
|
413
667
|
cwd: dir,
|
|
414
668
|
detached: true,
|
|
415
669
|
stdio: ['ignore', fs.openSync(path.join(logDir, 'walle.log'), 'a'), fs.openSync(path.join(logDir, 'walle.err'), 'a')],
|
|
@@ -425,12 +679,18 @@ function installService(walleDir, port) {
|
|
|
425
679
|
|
|
426
680
|
if (process.platform === 'darwin') {
|
|
427
681
|
const nodePath = process.execPath;
|
|
682
|
+
// Prefer the notarized app's node (downloadable app) → self-signed CTM bundle → node.
|
|
683
|
+
const launchExec = daemonExec();
|
|
428
684
|
const plistDir = path.join(process.env.HOME, 'Library', 'LaunchAgents');
|
|
429
685
|
fs.mkdirSync(plistDir, { recursive: true });
|
|
430
686
|
const plistPath = path.join(plistDir, `${LABEL}.plist`);
|
|
431
687
|
|
|
432
688
|
const xmlEsc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
433
|
-
|
|
689
|
+
// Put the notarized node's dir first (when adopted) so bare `node` invocations inside the
|
|
690
|
+
// daemon resolve to the same ABI it runs under; keep the real node dir as a fallback.
|
|
691
|
+
const notarizedNode = validatedNotarizedNode();
|
|
692
|
+
const nodeBinDir = [notarizedNode && path.dirname(notarizedNode), path.dirname(nodePath)]
|
|
693
|
+
.filter(Boolean).join(':');
|
|
434
694
|
let envDict = ` <key>PATH</key>\n <string>${xmlEsc(nodeBinDir)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>\n`;
|
|
435
695
|
envDict += ` <key>HOME</key>\n <string>${xmlEsc(process.env.HOME)}</string>\n`;
|
|
436
696
|
envDict += ` <key>CTM_PORT</key>\n <string>${port}</string>\n`;
|
|
@@ -453,7 +713,7 @@ function installService(walleDir, port) {
|
|
|
453
713
|
<string>${LABEL}</string>
|
|
454
714
|
<key>ProgramArguments</key>
|
|
455
715
|
<array>
|
|
456
|
-
<string>${
|
|
716
|
+
<string>${launchExec}</string>
|
|
457
717
|
<string>${walleDir}/claude-task-manager/server.js</string>
|
|
458
718
|
</array>
|
|
459
719
|
<key>WorkingDirectory</key>
|
|
@@ -480,10 +740,114 @@ ${envDict} </dict>
|
|
|
480
740
|
fs.writeFileSync(plistPath, plist);
|
|
481
741
|
try { execFileSync('launchctl', ['unload', plistPath], { stdio: 'ignore' }); } catch {}
|
|
482
742
|
execFileSync('launchctl', ['load', plistPath]);
|
|
743
|
+
} else if (process.platform === 'linux') {
|
|
744
|
+
// Always register desktop integration (name + icon in launchers / system monitors),
|
|
745
|
+
// independent of the service manager.
|
|
746
|
+
installDesktopIntegration(walleDir, port);
|
|
747
|
+
if (hasSystemdUser()) {
|
|
748
|
+
installSystemdService(walleDir, port, logDir);
|
|
749
|
+
} else {
|
|
750
|
+
installWatchdogFallback(walleDir, port, logDir);
|
|
751
|
+
}
|
|
483
752
|
} else {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
753
|
+
installWatchdogFallback(walleDir, port, logDir);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ── Linux service / desktop integration ──
|
|
758
|
+
|
|
759
|
+
// True only when a systemd *user* instance is actually reachable (catches non-systemd
|
|
760
|
+
// distros, bare SSH without lingering, WSL without systemd).
|
|
761
|
+
function hasSystemdUser() {
|
|
762
|
+
if (process.platform !== 'linux') return false;
|
|
763
|
+
try {
|
|
764
|
+
execFileSync('systemctl', ['--user', 'show-environment'], { stdio: 'ignore', timeout: 4000 });
|
|
765
|
+
return true;
|
|
766
|
+
} catch { return false; }
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function installSystemdService(walleDir, port, logDir) {
|
|
770
|
+
const unitDir = path.join(process.env.HOME, '.config', 'systemd', 'user');
|
|
771
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
772
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
773
|
+
const sq = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
774
|
+
let envLines = `Environment="CTM_PORT=${port}"\nEnvironment="CTM_MANAGED_BY_SYSTEMD=1"\n`;
|
|
775
|
+
try {
|
|
776
|
+
for (const line of fs.readFileSync(path.join(walleDir, '.env'), 'utf8').split('\n')) {
|
|
777
|
+
const m = line.match(/^\s*([^#=\s]+)\s*=\s*(.+?)\s*$/);
|
|
778
|
+
if (m && m[1] !== 'CTM_PORT') envLines += `Environment="${sq(m[1])}=${sq(m[2])}"\n`;
|
|
779
|
+
}
|
|
780
|
+
} catch {}
|
|
781
|
+
const unit = `[Unit]
|
|
782
|
+
Description=Wall-E — personal digital twin (Coding Task Manager)
|
|
783
|
+
After=network-online.target
|
|
784
|
+
Wants=network-online.target
|
|
785
|
+
|
|
786
|
+
[Service]
|
|
787
|
+
Type=simple
|
|
788
|
+
WorkingDirectory=${walleDir}
|
|
789
|
+
ExecStart=${process.execPath} ${walleDir}/claude-task-manager/server.js
|
|
790
|
+
Restart=on-failure
|
|
791
|
+
RestartSec=5
|
|
792
|
+
StartLimitIntervalSec=300
|
|
793
|
+
StartLimitBurst=5
|
|
794
|
+
${envLines}StandardOutput=journal
|
|
795
|
+
StandardError=journal
|
|
796
|
+
SyslogIdentifier=walle
|
|
797
|
+
|
|
798
|
+
[Install]
|
|
799
|
+
WantedBy=default.target
|
|
800
|
+
`;
|
|
801
|
+
fs.writeFileSync(path.join(unitDir, 'walle.service'), unit);
|
|
802
|
+
try { execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' }); } catch {}
|
|
803
|
+
try { execFileSync('systemctl', ['--user', 'enable', '--now', 'walle.service'], { stdio: 'ignore' }); } catch {}
|
|
804
|
+
try { execFileSync('systemctl', ['--user', 'restart', 'walle.service'], { stdio: 'ignore' }); } catch {}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Install a freedesktop .desktop entry + themed icons so Wall-E shows with a name + icon
|
|
808
|
+
// in app launchers and graphical system monitors. Best-effort.
|
|
809
|
+
function installDesktopIntegration(walleDir, port) {
|
|
810
|
+
if (process.platform !== 'linux') return;
|
|
811
|
+
try {
|
|
812
|
+
const appsDir = path.join(process.env.HOME, '.local', 'share', 'applications');
|
|
813
|
+
const themeDir = path.join(process.env.HOME, '.local', 'share', 'icons', 'hicolor');
|
|
814
|
+
fs.mkdirSync(appsDir, { recursive: true });
|
|
815
|
+
const pub = path.join(walleDir, 'claude-task-manager', 'public');
|
|
816
|
+
const svg = path.join(pub, 'icon.svg');
|
|
817
|
+
if (fs.existsSync(svg)) {
|
|
818
|
+
const d = path.join(themeDir, 'scalable', 'apps');
|
|
819
|
+
fs.mkdirSync(d, { recursive: true });
|
|
820
|
+
try { fs.copyFileSync(svg, path.join(d, 'walle.svg')); } catch {}
|
|
821
|
+
}
|
|
822
|
+
for (const [file, size] of [['icon-16.png', '16x16'], ['icon-32.png', '32x32'], ['icon-512.png', '512x512']]) {
|
|
823
|
+
const src = path.join(pub, file);
|
|
824
|
+
if (fs.existsSync(src)) {
|
|
825
|
+
const d = path.join(themeDir, size, 'apps');
|
|
826
|
+
fs.mkdirSync(d, { recursive: true });
|
|
827
|
+
try { fs.copyFileSync(src, path.join(d, 'walle.png')); } catch {}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
fs.writeFileSync(path.join(appsDir, 'walle.desktop'), `[Desktop Entry]
|
|
831
|
+
Type=Application
|
|
832
|
+
Name=Wall-E
|
|
833
|
+
GenericName=Personal Digital Twin
|
|
834
|
+
Comment=Coding Task Manager and Wall-E memory daemon
|
|
835
|
+
Exec=xdg-open http://localhost:${port}
|
|
836
|
+
Icon=walle
|
|
837
|
+
Terminal=false
|
|
838
|
+
Categories=Development;Utility;
|
|
839
|
+
Keywords=walle;ctm;claude;tasks;
|
|
840
|
+
StartupNotify=false
|
|
841
|
+
`);
|
|
842
|
+
try { execFileSync('gtk-update-icon-cache', ['-f', '-t', themeDir], { stdio: 'ignore' }); } catch {}
|
|
843
|
+
try { execFileSync('update-desktop-database', [appsDir], { stdio: 'ignore' }); } catch {}
|
|
844
|
+
} catch {}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function installWatchdogFallback(walleDir, port, logDir) {
|
|
848
|
+
// Linux/other without systemd: spawn with a restart-on-crash wrapper shell script.
|
|
849
|
+
const watchdogScript = path.join(walleDir, 'bin', 'watchdog.sh');
|
|
850
|
+
const watchdogContent = `#!/bin/bash
|
|
487
851
|
# Auto-restart CTM on crash. Clean exit (code 0) stops the watchdog.
|
|
488
852
|
CRASHES=0
|
|
489
853
|
while true; do
|
|
@@ -502,28 +866,37 @@ while true; do
|
|
|
502
866
|
sleep 5
|
|
503
867
|
done
|
|
504
868
|
`;
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
}
|
|
869
|
+
fs.mkdirSync(path.join(walleDir, 'bin'), { recursive: true });
|
|
870
|
+
fs.writeFileSync(watchdogScript, watchdogContent, { mode: 0o755 });
|
|
871
|
+
|
|
872
|
+
const child = spawn('bash', [watchdogScript], {
|
|
873
|
+
cwd: walleDir,
|
|
874
|
+
detached: true,
|
|
875
|
+
stdio: 'ignore',
|
|
876
|
+
env: { ...process.env, CTM_PORT: port },
|
|
877
|
+
});
|
|
878
|
+
child.unref();
|
|
879
|
+
fs.writeFileSync(path.join(logDir, 'walle.pid'), String(child.pid));
|
|
517
880
|
}
|
|
518
881
|
|
|
519
882
|
// ── Helpers ──
|
|
520
883
|
|
|
521
884
|
function npmInstall(dir) {
|
|
885
|
+
// Build native modules under the node the daemon will actually run (the notarized node when
|
|
886
|
+
// adopted), so its ABI matches. Safety: if we can't run npm UNDER that node (no co-located
|
|
887
|
+
// npm-cli.js → npm would fall back to the user's node and build the wrong ABI), don't adopt
|
|
888
|
+
// the notarized node at all — keeping one consistent ABI beats a half-applied switch.
|
|
889
|
+
let nodeBin = daemonNodeForBuild();
|
|
890
|
+
if (nodeBin !== process.execPath && resolveNpmRunner(process.env, nodeBin).type !== 'node-cli') {
|
|
891
|
+
console.log(` ${DIM}No npm co-located with the notarized node — keeping the default daemon node${RESET}`);
|
|
892
|
+
disableNotarizedNode();
|
|
893
|
+
nodeBin = process.execPath;
|
|
894
|
+
}
|
|
522
895
|
try {
|
|
523
896
|
for (const relDir of MANAGED_PACKAGE_DIRS) {
|
|
524
|
-
runNpm(path.join(dir, relDir), ['install', '--loglevel=warn']);
|
|
897
|
+
runNpm(path.join(dir, relDir), ['install', '--loglevel=warn'], nodeBin);
|
|
525
898
|
}
|
|
526
|
-
repairNativeDependencies(dir, { phase: 'install' });
|
|
899
|
+
repairNativeDependencies(dir, { phase: 'install', nodeBin });
|
|
527
900
|
} catch (err) {
|
|
528
901
|
console.error(`\n ${RED}npm install failed.${RESET}`);
|
|
529
902
|
if (err && err.message) console.error(` ${DIM}${err.message}${RESET}`);
|
|
@@ -537,6 +910,7 @@ function repairNativeDependencies(walleDir, {
|
|
|
537
910
|
checkDependency = checkNativeDependency,
|
|
538
911
|
runNpmCommand = runNpm,
|
|
539
912
|
log = console.log,
|
|
913
|
+
nodeBin = process.execPath,
|
|
540
914
|
} = {}) {
|
|
541
915
|
const repairs = [];
|
|
542
916
|
for (const relDir of MANAGED_PACKAGE_DIRS) {
|
|
@@ -546,17 +920,17 @@ function repairNativeDependencies(walleDir, {
|
|
|
546
920
|
|
|
547
921
|
const failed = [];
|
|
548
922
|
for (const dep of deps) {
|
|
549
|
-
const check = checkDependency(packageDir, dep);
|
|
923
|
+
const check = checkDependency(packageDir, dep, nodeBin);
|
|
550
924
|
if (!check.ok) failed.push(dep);
|
|
551
925
|
}
|
|
552
926
|
if (!failed.length) continue;
|
|
553
927
|
|
|
554
|
-
log(` ${YELLOW}Rebuilding native modules for ${relDir}${RESET} ${DIM}(
|
|
555
|
-
runNpmCommand(packageDir, ['rebuild', ...failed, '--loglevel=warn']);
|
|
928
|
+
log(` ${YELLOW}Rebuilding native modules for ${relDir}${RESET} ${DIM}(daemon node ${nodeBin !== process.execPath ? `notarized v${NOTARIZED_NODE_VERSION}` : process.version})${RESET}`);
|
|
929
|
+
runNpmCommand(packageDir, ['rebuild', ...failed, '--loglevel=warn'], nodeBin);
|
|
556
930
|
|
|
557
931
|
const stillBroken = [];
|
|
558
932
|
for (const dep of failed) {
|
|
559
|
-
const check = checkDependency(packageDir, dep);
|
|
933
|
+
const check = checkDependency(packageDir, dep, nodeBin);
|
|
560
934
|
if (!check.ok) stillBroken.push(`${dep}: ${firstLine(check.error)}`);
|
|
561
935
|
}
|
|
562
936
|
if (stillBroken.length) {
|
|
@@ -586,14 +960,14 @@ function nativeDependenciesForPackage(packageDir) {
|
|
|
586
960
|
return Object.keys(declared).filter((name) => NATIVE_DEPENDENCIES.has(name));
|
|
587
961
|
}
|
|
588
962
|
|
|
589
|
-
function checkNativeDependency(packageDir, dependency) {
|
|
963
|
+
function checkNativeDependency(packageDir, dependency, nodeBin = process.execPath) {
|
|
590
964
|
try {
|
|
591
|
-
execFileSync(
|
|
965
|
+
execFileSync(nodeBin, ['-e', `require(${JSON.stringify(dependency)})`], {
|
|
592
966
|
cwd: packageDir,
|
|
593
967
|
encoding: 'utf8',
|
|
594
968
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
595
969
|
timeout: 15000,
|
|
596
|
-
env: npmChildEnv(),
|
|
970
|
+
env: npmChildEnv(nodeBin),
|
|
597
971
|
});
|
|
598
972
|
return { ok: true };
|
|
599
973
|
} catch (err) {
|
|
@@ -602,20 +976,21 @@ function checkNativeDependency(packageDir, dependency) {
|
|
|
602
976
|
}
|
|
603
977
|
}
|
|
604
978
|
|
|
605
|
-
function runNpm(cwd, args) {
|
|
606
|
-
const runner = resolveNpmRunner();
|
|
979
|
+
function runNpm(cwd, args, nodeBin = process.execPath) {
|
|
980
|
+
const runner = resolveNpmRunner(process.env, nodeBin);
|
|
607
981
|
if (runner.type === 'node-cli') {
|
|
608
|
-
|
|
982
|
+
// Run npm UNDER nodeBin so node-gyp targets nodeBin's ABI (= the daemon's runtime).
|
|
983
|
+
execFileSync(nodeBin, [runner.path, ...args], {
|
|
609
984
|
cwd,
|
|
610
985
|
stdio: 'inherit',
|
|
611
|
-
env: npmChildEnv(),
|
|
986
|
+
env: npmChildEnv(nodeBin),
|
|
612
987
|
});
|
|
613
988
|
return;
|
|
614
989
|
}
|
|
615
990
|
execFileSync('npm', args, {
|
|
616
991
|
cwd,
|
|
617
992
|
stdio: 'inherit',
|
|
618
|
-
env: npmChildEnv(),
|
|
993
|
+
env: npmChildEnv(nodeBin),
|
|
619
994
|
});
|
|
620
995
|
}
|
|
621
996
|
|
|
@@ -647,10 +1022,11 @@ function npmCliCandidates(execPath = process.execPath) {
|
|
|
647
1022
|
].map((candidate) => path.resolve(candidate));
|
|
648
1023
|
}
|
|
649
1024
|
|
|
650
|
-
function npmChildEnv() {
|
|
651
|
-
const nodeDir = path.dirname(
|
|
1025
|
+
function npmChildEnv(nodeBin = process.execPath) {
|
|
1026
|
+
const nodeDir = path.dirname(nodeBin);
|
|
652
1027
|
return {
|
|
653
1028
|
...process.env,
|
|
1029
|
+
// nodeBin's dir first so any `node` node-gyp shells out to is the same ABI we're targeting.
|
|
654
1030
|
PATH: [nodeDir, process.env.PATH || ''].filter(Boolean).join(path.delimiter),
|
|
655
1031
|
npm_config_update_notifier: 'false',
|
|
656
1032
|
};
|
|
@@ -728,19 +1104,397 @@ function detectTimezone() {
|
|
|
728
1104
|
return 'UTC';
|
|
729
1105
|
}
|
|
730
1106
|
|
|
1107
|
+
// Build the global screenshot hotkey as a real LSUIElement .app bundle (a peer of the
|
|
1108
|
+
// Coding Task Manager.app / Wall-E.app daemon bundles). RegisterEventHotKey only delivers global
|
|
1109
|
+
// hotkeys to a GUI app the window server recognizes — a BARE launchd executable never receives the
|
|
1110
|
+
// keypress, which is why the old bare-binary build registered but never fired. Best-effort: if this
|
|
1111
|
+
// fails (no swiftc), CTM's boot-time ensureHotkeyDaemon self-heals it on first launch.
|
|
731
1112
|
function compileHotkeyDaemon(walleDir) {
|
|
732
1113
|
if (process.platform !== 'darwin') return; // macOS only
|
|
733
1114
|
const swiftSource = path.join(walleDir, 'claude-task-manager', 'bin', 'ctm-hotkey.swift');
|
|
734
1115
|
if (!fs.existsSync(swiftSource)) return;
|
|
735
|
-
const
|
|
1116
|
+
const bundleDir = path.join(BUNDLE_ROOT, 'CTM-Screenshot.app');
|
|
1117
|
+
const exec = path.join(bundleDir, 'Contents', 'MacOS', 'ctm-hotkey');
|
|
1118
|
+
try {
|
|
1119
|
+
fs.mkdirSync(path.dirname(exec), { recursive: true });
|
|
1120
|
+
// Carbon: RegisterEventHotKey / InstallEventHandler. Cocoa: NSApplication (the GUI app context).
|
|
1121
|
+
execFileSync('swiftc', ['-O', '-o', exec, swiftSource, '-framework', 'Cocoa', '-framework', 'Carbon'], { timeout: 120000, stdio: 'pipe' });
|
|
1122
|
+
fs.chmodSync(exec, 0o755);
|
|
1123
|
+
fs.writeFileSync(path.join(bundleDir, 'Contents', 'Info.plist'), `<?xml version="1.0" encoding="UTF-8"?>
|
|
1124
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1125
|
+
<plist version="1.0">
|
|
1126
|
+
<dict>
|
|
1127
|
+
<key>CFBundleName</key><string>CTM Screenshot</string>
|
|
1128
|
+
<key>CFBundleDisplayName</key><string>CTM Screenshot</string>
|
|
1129
|
+
<key>CFBundleIdentifier</key><string>com.walle.ctm.hotkey</string>
|
|
1130
|
+
<key>CFBundleExecutable</key><string>ctm-hotkey</string>
|
|
1131
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
1132
|
+
<key>CFBundleVersion</key><string>1.0</string>
|
|
1133
|
+
<key>CFBundleShortVersionString</key><string>1.0</string>
|
|
1134
|
+
<key>LSUIElement</key><true/>
|
|
1135
|
+
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
|
1136
|
+
<key>NSPrincipalClass</key><string>NSApplication</string>
|
|
1137
|
+
</dict>
|
|
1138
|
+
</plist>
|
|
1139
|
+
`);
|
|
1140
|
+
// Sign so it runs for every user (arm64 won't execute an unsigned binary): self-signed local
|
|
1141
|
+
// cert if available, else ad-hoc. CTM's boot ensureHotkeyDaemon upgrades to Developer-ID on
|
|
1142
|
+
// machines that have one. The hotkey needs no TCC permission, so any valid signature suffices.
|
|
1143
|
+
const id = ensureSigningIdentity();
|
|
1144
|
+
if (!id || !signArtifacts(id, [{ path: bundleDir, id: 'com.walle.ctm.hotkey', deep: true }])) {
|
|
1145
|
+
try { execFileSync('codesign', ['--force', '--deep', '--sign', '-', bundleDir], { stdio: 'ignore', timeout: 120000 }); } catch {}
|
|
1146
|
+
}
|
|
1147
|
+
// Remove any pre-bundle bare binary so two daemons can't compete for the combo.
|
|
1148
|
+
try { fs.unlinkSync(path.join(process.env.HOME, '.local', 'bin', 'ctm-hotkey')); } catch {}
|
|
1149
|
+
console.log(` ${GREEN}Built hotkey app bundle${RESET} ${DIM}(${bundleDir})${RESET}`);
|
|
1150
|
+
} catch {
|
|
1151
|
+
// swiftc not available or build failed — non-fatal; CTM's boot self-heal will retry.
|
|
1152
|
+
console.log(` ${DIM}Skipped hotkey bundle (Swift compiler not available)${RESET}`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// Compile the Screen Recording permission helper (macOS only). CTM spawns this when a
|
|
1157
|
+
// screenshot fails for lack of Screen Recording permission; calling
|
|
1158
|
+
// CGRequestScreenCaptureAccess() from a CTM child attributes the request to CTM's identity
|
|
1159
|
+
// so the system prompt reads "Coding Task Manager" and the grant lands on the right
|
|
1160
|
+
// identity. Best-effort: missing swiftc → screenshots just surface a manual-grant hint.
|
|
1161
|
+
function compileScreenAuthHelper(walleDir) {
|
|
1162
|
+
if (process.platform !== 'darwin') return; // macOS only
|
|
1163
|
+
const swiftSource = path.join(walleDir, 'claude-task-manager', 'bin', 'ctm-screen-auth.swift');
|
|
1164
|
+
if (!fs.existsSync(swiftSource)) return;
|
|
1165
|
+
const binary = path.join(process.env.HOME, '.local', 'bin', 'ctm-screen-auth');
|
|
1166
|
+
try {
|
|
1167
|
+
fs.mkdirSync(path.dirname(binary), { recursive: true });
|
|
1168
|
+
execFileSync('swiftc', ['-O', '-o', binary, swiftSource, '-framework', 'CoreGraphics'], { timeout: 120000, stdio: 'pipe' });
|
|
1169
|
+
fs.chmodSync(binary, 0o755);
|
|
1170
|
+
console.log(` ${GREEN}Compiled screen-recording helper${RESET} ${DIM}(${binary})${RESET}`);
|
|
1171
|
+
} catch {
|
|
1172
|
+
console.log(` ${DIM}Skipped screen-recording helper (Swift compiler not available)${RESET}`);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Compile the disclaim helper (macOS only). Lets CTM run `screencapture` as its own
|
|
1177
|
+
// responsible TCC process via the user's already-granted `node`, so screenshots work even
|
|
1178
|
+
// though CTM runs from a self-signed bundle macOS won't prompt for. Best-effort.
|
|
1179
|
+
function compileDisclaimHelper(walleDir) {
|
|
1180
|
+
if (process.platform !== 'darwin') return; // macOS only
|
|
1181
|
+
const src = path.join(walleDir, 'claude-task-manager', 'bin', 'ctm-disclaim.c');
|
|
1182
|
+
if (!fs.existsSync(src)) return;
|
|
1183
|
+
const binary = path.join(process.env.HOME, '.local', 'bin', 'ctm-disclaim');
|
|
736
1184
|
try {
|
|
737
1185
|
fs.mkdirSync(path.dirname(binary), { recursive: true });
|
|
738
|
-
execFileSync('
|
|
1186
|
+
execFileSync('clang', ['-O2', '-o', binary, src], { timeout: 120000, stdio: 'pipe' });
|
|
739
1187
|
fs.chmodSync(binary, 0o755);
|
|
740
|
-
console.log(` ${GREEN}Compiled
|
|
1188
|
+
console.log(` ${GREEN}Compiled screenshot disclaim helper${RESET} ${DIM}(${binary})${RESET}`);
|
|
1189
|
+
} catch {
|
|
1190
|
+
console.log(` ${DIM}Skipped disclaim helper (clang not available)${RESET}`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Resolve a REAL node binary (never a bundle exec — guards against copying the
|
|
1195
|
+
// bundle's own node onto itself once CTM runs from the bundle after migration).
|
|
1196
|
+
function resolveRealNode() {
|
|
1197
|
+
let n = process.execPath;
|
|
1198
|
+
if (!n || n.includes('.app/Contents/MacOS/')) {
|
|
1199
|
+
try { n = execFileSync('node', ['-e', 'process.stdout.write(process.execPath)'], { encoding: 'utf8' }).trim(); } catch {}
|
|
1200
|
+
}
|
|
1201
|
+
return n;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Clone (APFS copy-on-write, near-free) or copy a binary into place, executable.
|
|
1205
|
+
function placeBinary(src, dest) {
|
|
1206
|
+
try { fs.rmSync(dest, { force: true }); } catch {}
|
|
1207
|
+
try { execFileSync('cp', ['-c', src, dest], { stdio: 'ignore' }); }
|
|
1208
|
+
catch { fs.copyFileSync(src, dest); }
|
|
1209
|
+
fs.chmodSync(dest, 0o755);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// A dynamically-linked node (e.g. Homebrew) references @rpath/libnode.*.dylib (and ICU)
|
|
1213
|
+
// resolved via its rpath @loader_path/../lib. When we clone node into Contents/MacOS, that
|
|
1214
|
+
// rpath resolves to Contents/lib — so we copy the transitive @rpath dylibs there and the
|
|
1215
|
+
// unchanged rpath finds them (no install_name_tool needed). Self-contained nodes have no
|
|
1216
|
+
// @rpath deps and this is a no-op. Returns the list of copied dylib paths (to sign).
|
|
1217
|
+
function bundleNodeDylibs(realNode, libDir) {
|
|
1218
|
+
const copied = [];
|
|
1219
|
+
const seen = new Set();
|
|
1220
|
+
const nodeLibDir = path.resolve(path.dirname(realNode), '..', 'lib');
|
|
1221
|
+
const rpathDeps = (bin) => {
|
|
1222
|
+
try {
|
|
1223
|
+
return execFileSync('otool', ['-L', bin], { encoding: 'utf8' })
|
|
1224
|
+
.split('\n').map((l) => l.trim().split(/\s+/)[0]).filter((d) => d.startsWith('@rpath/'));
|
|
1225
|
+
} catch { return []; }
|
|
1226
|
+
};
|
|
1227
|
+
const rpathsOf = (bin) => {
|
|
1228
|
+
const rp = [];
|
|
1229
|
+
try {
|
|
1230
|
+
const o = execFileSync('otool', ['-l', bin], { encoding: 'utf8' });
|
|
1231
|
+
const re = /cmd LC_RPATH[\s\S]*?path (.+?) \(offset/g;
|
|
1232
|
+
let m; while ((m = re.exec(o))) rp.push(m[1].trim());
|
|
1233
|
+
} catch {}
|
|
1234
|
+
return rp;
|
|
1235
|
+
};
|
|
1236
|
+
const resolveSrc = (dep, binPath, binOrigDir) => {
|
|
1237
|
+
const name = dep.slice('@rpath/'.length);
|
|
1238
|
+
for (const rp of rpathsOf(binPath)) {
|
|
1239
|
+
const base = rp.replace('@loader_path', binOrigDir).replace('@executable_path', path.dirname(realNode));
|
|
1240
|
+
const cand = path.resolve(base, name);
|
|
1241
|
+
if (fs.existsSync(cand)) return cand;
|
|
1242
|
+
}
|
|
1243
|
+
const fb = path.join(nodeLibDir, name); // node's own lib dir (covers Homebrew layout)
|
|
1244
|
+
return fs.existsSync(fb) ? fb : null;
|
|
1245
|
+
};
|
|
1246
|
+
const walk = (binPath, binOrigDir) => {
|
|
1247
|
+
for (const dep of rpathDeps(binPath)) {
|
|
1248
|
+
const name = dep.slice('@rpath/'.length);
|
|
1249
|
+
if (seen.has(name)) continue;
|
|
1250
|
+
const src = resolveSrc(dep, binPath, binOrigDir);
|
|
1251
|
+
if (!src) continue;
|
|
1252
|
+
seen.add(name);
|
|
1253
|
+
fs.mkdirSync(libDir, { recursive: true });
|
|
1254
|
+
const dest = path.join(libDir, name);
|
|
1255
|
+
placeBinary(src, dest);
|
|
1256
|
+
copied.push(dest);
|
|
1257
|
+
walk(dest, path.dirname(src)); // recurse; resolve nested deps against the source's dir
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
walk(realNode, path.dirname(realNode));
|
|
1261
|
+
return copied;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function writeInfoPlist(plistPath, b, version) {
|
|
1265
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1266
|
+
fs.writeFileSync(plistPath, `<?xml version="1.0" encoding="UTF-8"?>
|
|
1267
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1268
|
+
<plist version="1.0">
|
|
1269
|
+
<dict>
|
|
1270
|
+
<key>CFBundleName</key><string>${esc(b.name)}</string>
|
|
1271
|
+
<key>CFBundleDisplayName</key><string>${esc(b.name)}</string>
|
|
1272
|
+
<key>CFBundleIdentifier</key><string>${esc(b.bundleId)}</string>
|
|
1273
|
+
<key>CFBundleExecutable</key><string>${esc(b.exec)}</string>
|
|
1274
|
+
<key>CFBundleIconFile</key><string>AppIcon</string>
|
|
1275
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
1276
|
+
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
|
1277
|
+
<key>CFBundleVersion</key><string>${esc(version)}</string>
|
|
1278
|
+
<key>CFBundleShortVersionString</key><string>${esc(version)}</string>
|
|
1279
|
+
<key>LSUIElement</key><true/>
|
|
1280
|
+
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
|
1281
|
+
</dict>
|
|
1282
|
+
</plist>
|
|
1283
|
+
`);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Generate an .icns from a square PNG via sips + iconutil (install-time fallback when
|
|
1287
|
+
// no prebuilt .icns shipped). Best-effort; throws if the toolchain is missing.
|
|
1288
|
+
function generateIcns(srcPng, outIcns) {
|
|
1289
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'walle-icns-'));
|
|
1290
|
+
try {
|
|
1291
|
+
const iconset = path.join(tmp, 'AppIcon.iconset');
|
|
1292
|
+
fs.mkdirSync(iconset, { recursive: true });
|
|
1293
|
+
for (const sz of [16, 32, 128, 256, 512]) {
|
|
1294
|
+
execFileSync('sips', ['-z', String(sz), String(sz), srcPng, '--out', path.join(iconset, `icon_${sz}x${sz}.png`)], { stdio: 'ignore' });
|
|
1295
|
+
execFileSync('sips', ['-z', String(sz * 2), String(sz * 2), srcPng, '--out', path.join(iconset, `icon_${sz}x${sz}@2x.png`)], { stdio: 'ignore' });
|
|
1296
|
+
}
|
|
1297
|
+
execFileSync('iconutil', ['-c', 'icns', iconset, '-o', outIcns], { stdio: 'ignore' });
|
|
1298
|
+
} finally {
|
|
1299
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function placeIcns(walleDir, b, resDir) {
|
|
1304
|
+
const out = path.join(resDir, 'AppIcon.icns');
|
|
1305
|
+
const shipped = path.join(walleDir, 'shared', 'icons', b.icns);
|
|
1306
|
+
if (fs.existsSync(shipped)) { try { fs.copyFileSync(shipped, out); return true; } catch {} }
|
|
1307
|
+
const png = path.join(walleDir, 'claude-task-manager', 'public', 'icon-512.png');
|
|
1308
|
+
if (fs.existsSync(png)) { try { generateIcns(png, out); return true; } catch {} }
|
|
1309
|
+
return false; // valid bundle without an icon (generic) — graceful
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// A clickable launcher app in ~/Applications (Launchpad / Spotlight / Dock) that brings
|
|
1313
|
+
// the CTM primary back up if it's down and opens the dashboard. Unlike the daemon bundles
|
|
1314
|
+
// (LSUIElement node), this is a normal app the user can launch when the server is dead —
|
|
1315
|
+
// a web button can't help then (nothing is serving the page). Returns the .app path or null.
|
|
1316
|
+
function buildLauncherApp(walleDir) {
|
|
1317
|
+
if (process.platform !== 'darwin') return null;
|
|
1318
|
+
try {
|
|
1319
|
+
const appsDir = process.env.WALLE_APPLICATIONS_DIR || path.join(process.env.HOME, 'Applications');
|
|
1320
|
+
const appDir = path.join(appsDir, 'Wall-E.app');
|
|
1321
|
+
const macosDir = path.join(appDir, 'Contents', 'MacOS');
|
|
1322
|
+
const resDir = path.join(appDir, 'Contents', 'Resources');
|
|
1323
|
+
fs.mkdirSync(macosDir, { recursive: true });
|
|
1324
|
+
fs.mkdirSync(resDir, { recursive: true });
|
|
1325
|
+
|
|
1326
|
+
let port = '3456';
|
|
1327
|
+
try { const m = fs.readFileSync(path.join(walleDir, '.env'), 'utf8').match(/^\s*CTM_PORT\s*=\s*(\d+)/m); if (m) port = m[1]; } catch {}
|
|
1328
|
+
|
|
1329
|
+
const exec = path.join(macosDir, 'Wall-E');
|
|
1330
|
+
fs.writeFileSync(exec, `#!/bin/bash
|
|
1331
|
+
# Wall-E launcher — start the CTM primary if it's down, then open the dashboard.
|
|
1332
|
+
PORT="${port}"
|
|
1333
|
+
UID_N="$(id -u)"
|
|
1334
|
+
if launchctl print "gui/$UID_N/com.walle.server" >/dev/null 2>&1; then
|
|
1335
|
+
launchctl kickstart "gui/$UID_N/com.walle.server" >/dev/null 2>&1 || true
|
|
1336
|
+
else
|
|
1337
|
+
PLIST="$HOME/Library/LaunchAgents/com.walle.server.plist"
|
|
1338
|
+
if [ -f "$PLIST" ]; then
|
|
1339
|
+
launchctl bootstrap "gui/$UID_N" "$PLIST" >/dev/null 2>&1 || true
|
|
1340
|
+
elif [ -f "${walleDir}/claude-task-manager/bin/restart-ctm.sh" ]; then
|
|
1341
|
+
bash "${walleDir}/claude-task-manager/bin/restart-ctm.sh" >/dev/null 2>&1 || true
|
|
1342
|
+
fi
|
|
1343
|
+
fi
|
|
1344
|
+
SCHEME="http"
|
|
1345
|
+
for i in $(seq 1 30); do
|
|
1346
|
+
if curl -s --max-time 1 "http://localhost:$PORT" >/dev/null 2>&1; then SCHEME="http"; break; fi
|
|
1347
|
+
if curl -sk --max-time 1 "https://localhost:$PORT" >/dev/null 2>&1; then SCHEME="https"; break; fi
|
|
1348
|
+
sleep 0.5
|
|
1349
|
+
done
|
|
1350
|
+
open "$SCHEME://localhost:$PORT" 2>/dev/null || true
|
|
1351
|
+
`, { mode: 0o755 });
|
|
1352
|
+
|
|
1353
|
+
const version = (() => { try { return String(require('../package.json').version || '0'); } catch { return '0'; } })();
|
|
1354
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1355
|
+
fs.writeFileSync(path.join(appDir, 'Contents', 'Info.plist'), `<?xml version="1.0" encoding="UTF-8"?>
|
|
1356
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1357
|
+
<plist version="1.0">
|
|
1358
|
+
<dict>
|
|
1359
|
+
<key>CFBundleName</key><string>Wall-E</string>
|
|
1360
|
+
<key>CFBundleDisplayName</key><string>Wall-E</string>
|
|
1361
|
+
<key>CFBundleIdentifier</key><string>com.walle.launcher</string>
|
|
1362
|
+
<key>CFBundleExecutable</key><string>Wall-E</string>
|
|
1363
|
+
<key>CFBundleIconFile</key><string>AppIcon</string>
|
|
1364
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
1365
|
+
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
|
1366
|
+
<key>CFBundleVersion</key><string>${esc(version)}</string>
|
|
1367
|
+
<key>CFBundleShortVersionString</key><string>${esc(version)}</string>
|
|
1368
|
+
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
|
1369
|
+
</dict>
|
|
1370
|
+
</plist>
|
|
1371
|
+
`);
|
|
1372
|
+
placeIcns(walleDir, { icns: 'AppIcon-walle.icns' }, resDir);
|
|
1373
|
+
return appDir;
|
|
1374
|
+
} catch { return null; }
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Idempotently ensure a stable self-signed code-signing identity in a dedicated keychain
|
|
1378
|
+
// (avoids login-keychain prompts; stable designated requirement so TCC survives updates).
|
|
1379
|
+
// Returns the identity common name (codesign --sign "<CN>" works with an untrusted
|
|
1380
|
+
// self-signed cert — find-identity -v would omit it). Null if signing is unavailable.
|
|
1381
|
+
function ensureSigningIdentity() {
|
|
1382
|
+
if (process.platform !== 'darwin') return null;
|
|
1383
|
+
const pw = 'walle';
|
|
1384
|
+
// Count CERTS by common name (trust-independent; find-identity -v hides untrusted certs).
|
|
1385
|
+
// codesign matches certs BY NAME, so 2 certs with this CN → "ambiguous" failures.
|
|
1386
|
+
const certCount = () => {
|
|
1387
|
+
try { return (execFileSync('security', ['find-certificate', '-a', '-c', SIGN_IDENTITY_CN, SIGN_KEYCHAIN], { encoding: 'utf8' }).match(/^keychain:/gm) || []).length; }
|
|
1388
|
+
catch { return 0; }
|
|
1389
|
+
};
|
|
1390
|
+
// The cert's SHA-1 (trust-independent via -Z). Sign by hash, not name: codesign --sign
|
|
1391
|
+
// "<CN>" scans the WHOLE search list and goes "ambiguous" if another keychain holds a
|
|
1392
|
+
// same-named cert; a hash matches exactly one cert. Same cert → same hash → stable DR.
|
|
1393
|
+
const identitySha = () => {
|
|
1394
|
+
try {
|
|
1395
|
+
const out = execFileSync('security', ['find-certificate', '-a', '-c', SIGN_IDENTITY_CN, '-Z', SIGN_KEYCHAIN], { encoding: 'utf8' });
|
|
1396
|
+
const m = out.match(/SHA-1 hash:\s*([0-9A-Fa-f]{40})/);
|
|
1397
|
+
return m ? m[1] : null;
|
|
1398
|
+
} catch { return null; }
|
|
1399
|
+
};
|
|
1400
|
+
try {
|
|
1401
|
+
if (fs.existsSync(SIGN_KEYCHAIN)) {
|
|
1402
|
+
let unlocked = false;
|
|
1403
|
+
try {
|
|
1404
|
+
execFileSync('security', ['unlock-keychain', '-p', pw, SIGN_KEYCHAIN], { stdio: 'ignore' });
|
|
1405
|
+
unlocked = true;
|
|
1406
|
+
} catch {}
|
|
1407
|
+
if (!unlocked && certCount() < 1) {
|
|
1408
|
+
try { execFileSync('security', ['delete-keychain', SIGN_KEYCHAIN], { stdio: 'ignore' }); } catch {}
|
|
1409
|
+
}
|
|
1410
|
+
// Reset to a single clean identity if duplicate certs accumulated (→ ambiguous).
|
|
1411
|
+
if (fs.existsSync(SIGN_KEYCHAIN) && certCount() > 1) { try { execFileSync('security', ['delete-keychain', SIGN_KEYCHAIN], { stdio: 'ignore' }); } catch {} }
|
|
1412
|
+
}
|
|
1413
|
+
fs.mkdirSync(path.dirname(SIGN_KEYCHAIN), { recursive: true });
|
|
1414
|
+
if (!fs.existsSync(SIGN_KEYCHAIN)) execFileSync('security', ['create-keychain', '-p', pw, SIGN_KEYCHAIN], { stdio: 'ignore' });
|
|
1415
|
+
execFileSync('security', ['set-keychain-settings', SIGN_KEYCHAIN], { stdio: 'ignore' }); // no auto-lock timeout
|
|
1416
|
+
execFileSync('security', ['unlock-keychain', '-p', pw, SIGN_KEYCHAIN], { stdio: 'ignore' });
|
|
1417
|
+
try {
|
|
1418
|
+
const list = execFileSync('security', ['list-keychains', '-d', 'user'], { encoding: 'utf8' })
|
|
1419
|
+
.split('\n').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
|
1420
|
+
if (!list.includes(SIGN_KEYCHAIN)) execFileSync('security', ['list-keychains', '-d', 'user', '-s', ...list, SIGN_KEYCHAIN], { stdio: 'ignore' });
|
|
1421
|
+
} catch {}
|
|
1422
|
+
if (certCount() < 1) {
|
|
1423
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'walle-sign-'));
|
|
1424
|
+
try {
|
|
1425
|
+
const keyPem = path.join(tmp, 'key.pem'), certPem = path.join(tmp, 'cert.pem'), p12 = path.join(tmp, 'id.p12');
|
|
1426
|
+
execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '3650', '-keyout', keyPem, '-out', certPem,
|
|
1427
|
+
'-subj', `/CN=${SIGN_IDENTITY_CN}`, '-addext', 'keyUsage=critical,digitalSignature', '-addext', 'extendedKeyUsage=critical,codeSigning'], { stdio: 'ignore' });
|
|
1428
|
+
execFileSync('openssl', ['pkcs12', '-export', '-inkey', keyPem, '-in', certPem, '-out', p12, '-passout', 'pass:' + pw], { stdio: 'ignore' });
|
|
1429
|
+
execFileSync('security', ['import', p12, '-k', SIGN_KEYCHAIN, '-P', pw, '-T', '/usr/bin/codesign', '-T', '/usr/bin/security'], { stdio: 'ignore' });
|
|
1430
|
+
} finally {
|
|
1431
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
// ALWAYS (re)authorize codesign to use the key non-interactively (reuse may have relocked).
|
|
1435
|
+
try { execFileSync('security', ['set-key-partition-list', '-S', 'apple-tool:,apple:', '-s', '-k', pw, SIGN_KEYCHAIN], { stdio: 'ignore' }); } catch {}
|
|
1436
|
+
return identitySha();
|
|
741
1437
|
} catch {
|
|
742
|
-
|
|
743
|
-
|
|
1438
|
+
return null;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function signArtifacts(identity, items) {
|
|
1443
|
+
if (!identity) return false;
|
|
1444
|
+
let ok = true;
|
|
1445
|
+
for (const it of items) {
|
|
1446
|
+
try {
|
|
1447
|
+
const args = ['--force', '--sign', identity, '--identifier', it.id, '--keychain', SIGN_KEYCHAIN];
|
|
1448
|
+
if (it.deep) args.push('--deep'); // also sign nested dylibs in Contents/lib (self-signed local use)
|
|
1449
|
+
args.push(it.path);
|
|
1450
|
+
execFileSync('codesign', args, { stdio: 'ignore', timeout: 120000 });
|
|
1451
|
+
} catch { ok = false; }
|
|
1452
|
+
}
|
|
1453
|
+
return ok;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Build (or refresh) the per-daemon .app bundles so CTM and Wall-E show a real icon +
|
|
1457
|
+
// name in Activity Monitor, then self-sign them and the hotkey for stable TCC. darwin-only,
|
|
1458
|
+
// best-effort: any failure logs a dim notice and leaves the install working (unsigned/
|
|
1459
|
+
// iconless at worst — process names still come from process.title).
|
|
1460
|
+
function buildAppBundles(walleDir) {
|
|
1461
|
+
if (process.platform !== 'darwin') return;
|
|
1462
|
+
try {
|
|
1463
|
+
const realNode = resolveRealNode();
|
|
1464
|
+
if (!realNode || !fs.existsSync(realNode)) return;
|
|
1465
|
+
const version = (() => { try { return String(require('../package.json').version || '0'); } catch { return '0'; } })();
|
|
1466
|
+
fs.mkdirSync(BUNDLE_ROOT, { recursive: true });
|
|
1467
|
+
// Record the real node we cloned from. At runtime CTM/Wall-E spawn their children from
|
|
1468
|
+
// this (not the self-signed bundle clone) so a multi-session restart doesn't bury the
|
|
1469
|
+
// macOS endpoint-security stack under a storm of novel-binary AUTH_EXEC evaluations.
|
|
1470
|
+
try { fs.writeFileSync(path.join(BUNDLE_ROOT, 'node-origin'), realNode + '\n'); } catch {}
|
|
1471
|
+
for (const b of APP_BUNDLES) {
|
|
1472
|
+
const appDir = path.join(BUNDLE_ROOT, b.app);
|
|
1473
|
+
const macosDir = path.join(appDir, 'Contents', 'MacOS');
|
|
1474
|
+
const resDir = path.join(appDir, 'Contents', 'Resources');
|
|
1475
|
+
fs.mkdirSync(macosDir, { recursive: true });
|
|
1476
|
+
fs.mkdirSync(resDir, { recursive: true });
|
|
1477
|
+
placeBinary(realNode, path.join(macosDir, b.exec));
|
|
1478
|
+
// Co-locate node's @rpath dylibs (libnode/ICU for dynamically-linked builds) so the
|
|
1479
|
+
// cloned exec runs from inside the bundle. No-op for a self-contained node.
|
|
1480
|
+
bundleNodeDylibs(realNode, path.join(appDir, 'Contents', 'lib'));
|
|
1481
|
+
writeInfoPlist(path.join(appDir, 'Contents', 'Info.plist'), b, version);
|
|
1482
|
+
placeIcns(walleDir, b, resDir);
|
|
1483
|
+
}
|
|
1484
|
+
// Clickable launcher in ~/Applications (Launchpad/Dock) to revive a dead primary.
|
|
1485
|
+
const launcher = buildLauncherApp(walleDir);
|
|
1486
|
+
const identity = ensureSigningIdentity();
|
|
1487
|
+
const items = APP_BUNDLES.map((b) => ({ path: path.join(BUNDLE_ROOT, b.app), id: b.bundleId, deep: true }));
|
|
1488
|
+
if (launcher) items.push({ path: launcher, id: 'com.walle.launcher', deep: true });
|
|
1489
|
+
// The hotkey is its own LSUIElement .app bundle under BUNDLE_ROOT (built by
|
|
1490
|
+
// compileHotkeyDaemon); sign it alongside the daemon bundles. It follows BUNDLE_ROOT, so a
|
|
1491
|
+
// WALLE_BUNDLE_DIR test dir signs the test bundle, never the live one.
|
|
1492
|
+
const hotkeyBundle = path.join(BUNDLE_ROOT, 'CTM-Screenshot.app');
|
|
1493
|
+
if (fs.existsSync(hotkeyBundle)) items.push({ path: hotkeyBundle, id: 'com.walle.ctm.hotkey', deep: true });
|
|
1494
|
+
const signed = signArtifacts(identity, items);
|
|
1495
|
+
console.log(` ${GREEN}Built app bundles + launcher${RESET} ${DIM}(${BUNDLE_ROOT}${signed ? ', signed' : ', unsigned'})${RESET}`);
|
|
1496
|
+
} catch (e) {
|
|
1497
|
+
console.log(` ${DIM}Skipped app bundles (${e && e.message ? e.message : e})${RESET}`);
|
|
744
1498
|
}
|
|
745
1499
|
}
|
|
746
1500
|
|
|
@@ -767,4 +1521,20 @@ module.exports = {
|
|
|
767
1521
|
repairNativeDependencies,
|
|
768
1522
|
resolveNpmRunner,
|
|
769
1523
|
writeCliLifecycleEvent,
|
|
1524
|
+
buildAppBundles,
|
|
1525
|
+
ensureSigningIdentity,
|
|
1526
|
+
signArtifacts,
|
|
1527
|
+
ctmBundleExec,
|
|
1528
|
+
walleBundleExec,
|
|
1529
|
+
execTeamIdentifier,
|
|
1530
|
+
daemonExec,
|
|
1531
|
+
daemonNodeForBuild,
|
|
1532
|
+
validatedNotarizedNode,
|
|
1533
|
+
verifyNotarizedNode,
|
|
1534
|
+
nodeReportsVersion,
|
|
1535
|
+
ensureNotarizedDaemonNode,
|
|
1536
|
+
disableNotarizedNode,
|
|
1537
|
+
notarizedNodeDir,
|
|
1538
|
+
notarizedNodePath,
|
|
1539
|
+
NOTARIZED_NODE_VERSION,
|
|
770
1540
|
};
|