@ghl-ai/aw 0.1.30-beta.3 → 0.1.30-beta.6

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/hooks.mjs +144 -0
  2. package/package.json +3 -2
  3. package/update.mjs +74 -21
package/hooks.mjs ADDED
@@ -0,0 +1,144 @@
1
+ // hooks.mjs — Global git hooks via core.hooksPath (git-lfs pattern).
2
+ //
3
+ // installGlobalHooks() → writes dispatcher scripts to ~/.aw/hooks/,
4
+ // sets core.hooksPath with chaining to .git/hooks/
5
+ // removeGlobalHooks() → restores previous core.hooksPath, removes ~/.aw/hooks/
6
+
7
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, rmSync, readdirSync } from 'node:fs';
8
+ import { execSync } from 'node:child_process';
9
+ import { join } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+ import * as fmt from './fmt.mjs';
12
+
13
+ const HOME = homedir();
14
+ const HOOKS_DIR = join(HOME, '.aw', 'hooks');
15
+ const PREV_PATH_FILE = join(HOOKS_DIR, '.previous-hooks-path');
16
+
17
+ // ── Dispatcher scripts ─────────────────────────────────────────────────
18
+ // Each hook: (1) runs aw logic in background, (2) chains to previous
19
+ // hooksPath if another tool owned it, (3) chains to repo-local .git/hooks/.
20
+ // `exec` replaces the shell process so the local hook's exit code propagates.
21
+
22
+ function makeDispatcher(hookName, awBlock) {
23
+ return `#!/bin/sh
24
+ # aw: global ${hookName} dispatcher (installed by aw init)
25
+ # Chains to local hooks — see https://git-scm.com/docs/githooks
26
+
27
+ # Skip if inside aw's own temp sparse checkout (prevents recursion)
28
+ case "$(pwd)" in /tmp/aw-*|/var/folders/*/aw-*) exit 0 ;; esac
29
+
30
+ ${awBlock}
31
+
32
+ # Chain to previous hooksPath (if another tool set it before aw)
33
+ PREV_PATH_FILE="$HOME/.aw/hooks/.previous-hooks-path"
34
+ if [ -f "$PREV_PATH_FILE" ]; then
35
+ PREV_HOOK="$(cat "$PREV_PATH_FILE")/${hookName}"
36
+ if [ -x "$PREV_HOOK" ]; then
37
+ exec "$PREV_HOOK" "$@"
38
+ fi
39
+ fi
40
+
41
+ # Chain to repo-local hook
42
+ GIT_DIR="$(git rev-parse --git-dir 2>/dev/null)"
43
+ if [ -n "$GIT_DIR" ]; then
44
+ LOCAL_HOOK="$GIT_DIR/hooks/${hookName}"
45
+ [ -x "$LOCAL_HOOK" ] && exec "$LOCAL_HOOK" "$@"
46
+ fi
47
+
48
+ exit 0
49
+ `;
50
+ }
51
+
52
+ const POST_MERGE = makeDispatcher('post-merge', `\
53
+ if command -v aw >/dev/null 2>&1; then
54
+ aw init --silent >/dev/null 2>&1 &
55
+ fi`);
56
+
57
+ const POST_CHECKOUT = makeDispatcher('post-checkout', `\
58
+ AW_REGISTRY="$HOME/.aw_registry"
59
+ if [ -d "$AW_REGISTRY" ] && [ ! -e ".aw_registry" ] && [ -d ".git" ]; then
60
+ ln -s "$AW_REGISTRY" ".aw_registry" 2>/dev/null
61
+ fi
62
+ if command -v aw >/dev/null 2>&1; then
63
+ aw init --silent >/dev/null 2>&1 &
64
+ fi`);
65
+
66
+ /**
67
+ * Install global git hooks at ~/.aw/hooks/ and set core.hooksPath.
68
+ * If core.hooksPath is already set by another tool, saves the previous
69
+ * value for chaining and restoration on uninstall.
70
+ * @returns {boolean} true if hooks were installed
71
+ */
72
+ export function installGlobalHooks() {
73
+ try {
74
+ mkdirSync(HOOKS_DIR, { recursive: true });
75
+
76
+ writeFileSync(join(HOOKS_DIR, 'post-merge'), POST_MERGE);
77
+ chmodSync(join(HOOKS_DIR, 'post-merge'), '755');
78
+
79
+ writeFileSync(join(HOOKS_DIR, 'post-checkout'), POST_CHECKOUT);
80
+ chmodSync(join(HOOKS_DIR, 'post-checkout'), '755');
81
+
82
+ // Detect and preserve existing core.hooksPath
83
+ let previousPath = null;
84
+ try {
85
+ previousPath = execSync('git config --global core.hooksPath', {
86
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
87
+ }).trim();
88
+ } catch { /* not set */ }
89
+
90
+ if (previousPath && previousPath !== HOOKS_DIR) {
91
+ writeFileSync(PREV_PATH_FILE, previousPath);
92
+ fmt.logStep(`Saved previous core.hooksPath (${previousPath}) for chaining`);
93
+ }
94
+
95
+ execSync(`git config --global core.hooksPath "${HOOKS_DIR}"`, { stdio: 'pipe' });
96
+ fmt.logStep('Global git hooks installed (auto-sync on pull/clone)');
97
+ return true;
98
+ } catch (e) {
99
+ fmt.logWarn(`Could not install global hooks: ${e.message}`);
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Remove global hooks and restore previous core.hooksPath if one existed.
106
+ */
107
+ export function removeGlobalHooks() {
108
+ // Restore previous core.hooksPath or unset
109
+ let restored = false;
110
+ if (existsSync(PREV_PATH_FILE)) {
111
+ try {
112
+ const prev = readFileSync(PREV_PATH_FILE, 'utf8').trim();
113
+ if (prev) {
114
+ execSync(`git config --global core.hooksPath "${prev}"`, { stdio: 'pipe' });
115
+ restored = true;
116
+ }
117
+ } catch { /* best effort */ }
118
+ }
119
+
120
+ if (!restored) {
121
+ try {
122
+ const current = execSync('git config --global core.hooksPath', {
123
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
124
+ }).trim();
125
+ if (current === HOOKS_DIR) {
126
+ execSync('git config --global --unset core.hooksPath', { stdio: 'pipe' });
127
+ }
128
+ } catch { /* not set or already unset */ }
129
+ }
130
+
131
+ if (existsSync(HOOKS_DIR)) {
132
+ rmSync(HOOKS_DIR, { recursive: true, force: true });
133
+ }
134
+
135
+ // Clean up parent ~/.aw/ if empty
136
+ const awDir = join(HOME, '.aw');
137
+ try {
138
+ if (existsSync(awDir) && readdirSync(awDir).length === 0) {
139
+ rmSync(awDir, { force: true });
140
+ }
141
+ } catch { /* best effort */ }
142
+
143
+ fmt.logStep('Global git hooks removed');
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.30-beta.3",
3
+ "version": "0.1.30-beta.6",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,8 @@
23
23
  "plan.mjs",
24
24
  "registry.mjs",
25
25
  "apply.mjs",
26
- "update.mjs"
26
+ "update.mjs",
27
+ "hooks.mjs"
27
28
  ],
28
29
  "engines": {
29
30
  "node": ">=18.0.0"
package/update.mjs CHANGED
@@ -4,11 +4,16 @@
4
4
  // notifyUpdate(r) → prints banner if r says update available
5
5
  // autoUpdate(r) → synchronous install with atomic lock, verification, logging
6
6
  // promptUpdate(r) → asks user interactively (normal init)
7
+ //
8
+ // Package manager detection uses import.meta.url (zero subprocesses):
9
+ // .volta/ → volta install pnpm/ → pnpm add -g
10
+ // else → npm i -g
7
11
 
8
- import { readFileSync, mkdirSync, writeFileSync, statSync, rmSync, appendFileSync, existsSync } from 'node:fs';
12
+ import { readFileSync, mkdirSync, writeFileSync, statSync, rmSync, appendFileSync } from 'node:fs';
9
13
  import { exec as execCb, execSync } from 'node:child_process';
10
14
  import { promisify } from 'node:util';
11
- import { join } from 'node:path';
15
+ import { join, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
12
17
  import { homedir } from 'node:os';
13
18
  import * as fmt from './fmt.mjs';
14
19
  import { chalk } from './fmt.mjs';
@@ -21,9 +26,40 @@ const LOG_PATH = join(homedir(), '.aw_registry', '.aw-upgrade.log');
21
26
  const LOCK_MAX_AGE_MS = 5 * 60 * 1000;
22
27
  const LOG_MAX_ENTRIES = 50;
23
28
 
29
+ // ── Package manager detection ──────────────────────────────────────────
30
+ // We ARE the installed binary. Our own file path tells us who installed us
31
+ // — no subprocess needed.
32
+ //
33
+ // import.meta.url examples:
34
+ // Volta: file:///Users/x/.volta/tools/image/packages/@ghl-ai/aw/...
35
+ // pnpm: file:///Users/x/.local/share/pnpm/global/.../node_modules/@ghl-ai/aw/...
36
+ // npm: file:///opt/homebrew/lib/node_modules/@ghl-ai/aw/...
37
+ // npm: file:///usr/local/lib/node_modules/@ghl-ai/aw/...
38
+ // link: file:///Users/x/code/ghl-agentic-workspace/libs/aw/... (npm link / dev)
39
+
40
+ const SELF_PATH = fileURLToPath(import.meta.url);
41
+
42
+ function detectPM() {
43
+ if (SELF_PATH.includes('/.volta/')) return 'volta';
44
+ if (SELF_PATH.includes('/pnpm/')) return 'pnpm';
45
+ return 'npm';
46
+ }
47
+
48
+ const PM = detectPM();
49
+
50
+ function getInstallCmd(version = 'latest') {
51
+ const spec = `${PKG_NAME}@${version}`;
52
+ if (PM === 'volta') return `volta install ${spec}`;
53
+ if (PM === 'pnpm') return `pnpm add -g ${spec}`;
54
+ return `npm i -g ${spec}`;
55
+ }
56
+
57
+ // ── Version helpers ────────────────────────────────────────────────────
58
+
24
59
  function getLocalVersion() {
25
60
  try {
26
- return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
61
+ const pkgPath = join(dirname(SELF_PATH), 'package.json');
62
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
27
63
  } catch {
28
64
  return '0.0.0';
29
65
  }
@@ -51,6 +87,32 @@ function compareVersions(a, b) {
51
87
  return 0;
52
88
  }
53
89
 
90
+ // ── Installed version verification ─────────────────────────────────────
91
+ // After install, re-resolve where the package actually landed and read
92
+ // its package.json. Falls back to getLocalVersion() (our own bundle).
93
+
94
+ function getInstalledVersion() {
95
+ try {
96
+ if (PM === 'volta') {
97
+ // Volta resolves via its own image; ask it directly
98
+ const resolved = execSync('volta which aw', {
99
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10_000,
100
+ }).trim();
101
+ const pkgRoot = join(resolved, '..', '..');
102
+ return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).version;
103
+ }
104
+
105
+ // npm / pnpm: read from the global prefix
106
+ const prefix = execSync('npm prefix -g', {
107
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10_000,
108
+ }).trim();
109
+ const pkgPath = join(prefix, 'lib', 'node_modules', PKG_NAME, 'package.json');
110
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
111
+ } catch {
112
+ return getLocalVersion();
113
+ }
114
+ }
115
+
54
116
  // ── Atomic lock via mkdir ──────────────────────────────────────────────
55
117
  // mkdir is atomic on all POSIX filesystems. Stale detection uses
56
118
  // directory mtime (OS-maintained) rather than parsing PIDs.
@@ -92,18 +154,6 @@ function appendLog(entry) {
92
154
  } catch { /* logging is best-effort */ }
93
155
  }
94
156
 
95
- // ── Installed version verification ─────────────────────────────────────
96
-
97
- function getInstalledVersion() {
98
- try {
99
- const prefix = execSync('npm prefix -g', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10_000 }).trim();
100
- const pkgPath = join(prefix, 'lib', 'node_modules', PKG_NAME, 'package.json');
101
- return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
102
- } catch {
103
- return getLocalVersion();
104
- }
105
- }
106
-
107
157
  /**
108
158
  * Fetch the latest version from npm (non-blocking, 15s timeout).
109
159
  * Call at the start of any command, await at the end.
@@ -125,10 +175,11 @@ export async function checkForUpdate() {
125
175
  */
126
176
  export function notifyUpdate(result) {
127
177
  if (!result?.hasUpdate) return;
178
+ const cmd = getInstallCmd(result.latest);
128
179
  const msg = [
129
180
  '',
130
181
  ` ${chalk.bgHex('#FF6B35').bold(' UPDATE ')} New version available: ${chalk.dim(result.current)} → ${chalk.green(result.latest)}`,
131
- ` ${chalk.dim('Run')} ${chalk.bold(`npm i -g ${PKG_NAME}`)} ${chalk.dim('to update')}`,
182
+ ` ${chalk.dim('Run')} ${chalk.bold(cmd)} ${chalk.dim('to update')}`,
132
183
  '',
133
184
  ].join('\n');
134
185
  console.log(msg);
@@ -144,15 +195,16 @@ export function autoUpdate(result) {
144
195
  if (isPrerelease(result.current)) return { status: 'skipped', reason: 'prerelease' };
145
196
  if (!acquireLock()) return { status: 'skipped', reason: 'locked' };
146
197
 
198
+ const cmd = getInstallCmd('latest');
147
199
  try {
148
- execSync(`npm i -g ${PKG_NAME}@latest`, { stdio: 'pipe', timeout: 60_000 });
200
+ execSync(cmd, { stdio: 'pipe', timeout: 60_000 });
149
201
  const installed = getInstalledVersion();
150
202
  const ok = compareVersions(installed, result.current) > 0;
151
- const entry = { ts: new Date().toISOString(), from: result.current, to: installed, ok };
203
+ const entry = { ts: new Date().toISOString(), pm: PM, cmd, from: result.current, to: installed, ok };
152
204
  appendLog(entry);
153
205
  return { status: ok ? 'upgraded' : 'failed', ...entry };
154
206
  } catch (e) {
155
- const entry = { ts: new Date().toISOString(), from: result.current, error: e.message, ok: false };
207
+ const entry = { ts: new Date().toISOString(), pm: PM, cmd, from: result.current, error: e.message, ok: false };
156
208
  appendLog(entry);
157
209
  return { status: 'failed', error: e.message };
158
210
  } finally {
@@ -168,6 +220,7 @@ export async function promptUpdate(result) {
168
220
  if (!result?.hasUpdate) return false;
169
221
  if (isPrerelease(result.current)) return false;
170
222
 
223
+ const cmd = getInstallCmd(result.latest);
171
224
  fmt.logWarn(`Update available: ${chalk.dim(result.current)} → ${chalk.green(result.latest)}`);
172
225
 
173
226
  const { default: clack } = await import('@clack/prompts');
@@ -184,14 +237,14 @@ export async function promptUpdate(result) {
184
237
  const s = fmt.spinner();
185
238
  s.start(`Updating to ${result.latest}...`);
186
239
  try {
187
- execSync(`npm i -g ${PKG_NAME}@latest`, { stdio: 'pipe', timeout: 60_000 });
240
+ execSync(cmd, { stdio: 'pipe', timeout: 60_000 });
188
241
  const installed = getInstalledVersion();
189
242
  const ok = compareVersions(installed, result.current) > 0;
190
243
  s.stop(ok ? `Updated to ${chalk.green(installed)}` : chalk.red('Update may have failed — verify with: aw --version'));
191
244
  return ok;
192
245
  } catch {
193
246
  s.stop(chalk.red('Update failed'));
194
- fmt.logWarn(`Manual update: ${chalk.bold(`npm i -g ${PKG_NAME}@latest`)}`);
247
+ fmt.logWarn(`Manual update: ${chalk.bold(cmd)}`);
195
248
  return false;
196
249
  }
197
250
  }