@ghl-ai/aw 0.1.30-beta.1 → 0.1.30-beta.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.
package/commands/init.mjs CHANGED
@@ -1,10 +1,10 @@
1
- // commands/init.mjs — Clean init: clone registry, link IDEs, git hooks for omnipresence.
1
+ // commands/init.mjs — Clean init: clone registry, link IDEs, global git hooks.
2
2
  //
3
3
  // No shell profile modifications. No daemons. No background processes.
4
- // Uses git's built-in template hooks for automatic linking on clone/checkout.
4
+ // Uses core.hooksPath (git-lfs pattern) for system-wide hook interception.
5
5
  // Uses IDE tasks for auto-pull on workspace open.
6
6
 
7
- import { mkdirSync, existsSync, writeFileSync, symlinkSync, chmodSync } from 'node:fs';
7
+ import { mkdirSync, existsSync, writeFileSync, symlinkSync } from 'node:fs';
8
8
  import { execSync } from 'node:child_process';
9
9
  import { join, dirname } from 'node:path';
10
10
  import { homedir } from 'node:os';
@@ -18,6 +18,7 @@ import { linkWorkspace } from '../link.mjs';
18
18
  import { generateCommands, copyInstructions, initAwDocs } from '../integrate.mjs';
19
19
  import { setupMcp } from '../mcp.mjs';
20
20
  import { autoUpdate, promptUpdate } from '../update.mjs';
21
+ import { installGlobalHooks } from '../hooks.mjs';
21
22
 
22
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
24
  const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
@@ -25,46 +26,6 @@ const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), '
25
26
  const HOME = homedir();
26
27
  const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
27
28
  const MANIFEST_PATH = join(GLOBAL_AW_DIR, '.aw-manifest.json');
28
- const GIT_TEMPLATE_DIR = join(HOME, '.git-templates', 'aw');
29
-
30
- // ── Git template hook for omnipresence ──────────────────────────────────
31
- // Git's init.templateDir copies hooks into every new clone/init.
32
- // post-checkout fires on clone and branch switch — perfect for auto-linking.
33
-
34
- const POST_CHECKOUT_HOOK = `#!/bin/sh
35
- # aw: auto-link registry on clone/checkout (installed by aw init)
36
- AW_REGISTRY="$HOME/.aw_registry"
37
- if [ -d "$AW_REGISTRY" ] && [ ! -e ".aw_registry" ] && [ -d ".git" ]; then
38
- ln -s "$AW_REGISTRY" ".aw_registry" 2>/dev/null
39
- fi
40
- `;
41
-
42
- function installGitTemplate() {
43
- const hooksDir = join(GIT_TEMPLATE_DIR, 'hooks');
44
- mkdirSync(hooksDir, { recursive: true });
45
-
46
- const hookPath = join(hooksDir, 'post-checkout');
47
- writeFileSync(hookPath, POST_CHECKOUT_HOOK);
48
- chmodSync(hookPath, '755');
49
-
50
- // Set git global template dir (merges with existing hooks)
51
- try {
52
- const current = execSync('git config --global init.templateDir', {
53
- encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
54
- }).trim();
55
-
56
- if (current && current !== GIT_TEMPLATE_DIR) {
57
- // Another template dir is set — don't override, just inform
58
- fmt.logWarn(`Git template dir already set to ${current}`);
59
- fmt.logStep(chalk.dim(' Skipping git template — run: git config --global init.templateDir ~/.git-templates/aw'));
60
- return false;
61
- }
62
- } catch { /* not set yet — good */ }
63
-
64
- execSync(`git config --global init.templateDir "${GIT_TEMPLATE_DIR}"`, { stdio: 'pipe' });
65
- fmt.logStep('Git template hook installed (auto-links on clone)');
66
- return true;
67
- }
68
29
 
69
30
  // ── IDE tasks for auto-pull ─────────────────────────────────────────────
70
31
 
@@ -237,12 +198,14 @@ export async function initCommand(args) {
237
198
  await Promise.all(pullJobs);
238
199
  }
239
200
 
240
- // Re-link IDE dirs (idempotent)
201
+ // Re-link IDE dirs + hooks (idempotent)
241
202
  linkWorkspace(HOME);
242
203
  generateCommands(HOME);
243
204
  copyInstructions(HOME, null, freshCfg?.namespace || team) || [];
244
205
  initAwDocs(HOME);
245
206
  setupMcp(HOME, freshCfg?.namespace || team) || [];
207
+ if (cwd !== HOME) setupMcp(cwd, freshCfg?.namespace || team);
208
+ installGlobalHooks();
246
209
 
247
210
  // Link current project if needed
248
211
  if (cwd !== HOME && !existsSync(join(cwd, '.aw_registry'))) {
@@ -321,7 +284,8 @@ export async function initCommand(args) {
321
284
  const instructionFiles = copyInstructions(HOME, null, team) || [];
322
285
  initAwDocs(HOME);
323
286
  const mcpFiles = setupMcp(HOME, team) || [];
324
- const gitTemplateInstalled = installGitTemplate();
287
+ if (cwd !== HOME) setupMcp(cwd, team);
288
+ const hooksInstalled = installGlobalHooks();
325
289
  installIdeTasks();
326
290
 
327
291
  // Step 4: Symlink in current directory if it's a git repo
@@ -341,7 +305,7 @@ export async function initCommand(args) {
341
305
  ...instructionFiles.map(p => p.startsWith(HOME) ? p.slice(HOME.length + 1) : p),
342
306
  ...mcpFiles.map(p => p.startsWith(HOME) ? p.slice(HOME.length + 1) : p),
343
307
  ],
344
- gitTemplate: gitTemplateInstalled ? GIT_TEMPLATE_DIR : null,
308
+ globalHooksDir: hooksInstalled ? join(HOME, '.aw', 'hooks') : null,
345
309
  };
346
310
  saveManifest(manifest);
347
311
 
@@ -354,13 +318,13 @@ export async function initCommand(args) {
354
318
  '',
355
319
  ` ${chalk.green('✓')} Source of truth: ~/.aw_registry/`,
356
320
  ` ${chalk.green('✓')} IDE integration: ~/.claude/, ~/.cursor/, ~/.codex/`,
357
- gitTemplateInstalled ? ` ${chalk.green('✓')} Git hook: new clones auto-link .aw_registry` : null,
321
+ hooksInstalled ? ` ${chalk.green('✓')} Git hooks: auto-sync on pull/clone (core.hooksPath)` : null,
358
322
  ` ${chalk.green('✓')} IDE task: auto-sync on workspace open`,
359
323
  cwd !== HOME && existsSync(join(cwd, '.aw_registry')) ? ` ${chalk.green('✓')} Linked in current project` : null,
360
324
  '',
361
325
  ` ${chalk.dim('Existing repos:')} ${chalk.bold('cd <project> && aw link')}`,
362
326
  ` ${chalk.dim('New clones:')} auto-linked via git hook`,
363
- ` ${chalk.dim('Update:')} ${chalk.bold('aw init')} ${chalk.dim('(or auto on IDE open)')}`,
327
+ ` ${chalk.dim('Update:')} ${chalk.bold('aw init')} ${chalk.dim('(or auto on pull/IDE open)')}`,
364
328
  ` ${chalk.dim('Uninstall:')} ${chalk.bold('aw nuke')}`,
365
329
  ].filter(Boolean).join('\n'));
366
330
  }
package/commands/nuke.mjs CHANGED
@@ -8,6 +8,7 @@ import { homedir } from 'node:os';
8
8
  import { execSync } from 'node:child_process';
9
9
  import * as fmt from '../fmt.mjs';
10
10
  import { chalk } from '../fmt.mjs';
11
+ import { removeGlobalHooks } from '../hooks.mjs';
11
12
 
12
13
  const HOME = homedir();
13
14
  const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
@@ -169,21 +170,24 @@ function removeProjectSymlinks() {
169
170
  if (removed > 0) fmt.logStep(`Removed ${removed} project .aw_registry symlink${removed > 1 ? 's' : ''}`);
170
171
  }
171
172
 
172
- // 4. Remove git template hook
173
- function removeGitTemplate(manifest) {
174
- const gitTemplateDir = manifest?.gitTemplate || join(HOME, '.git-templates', 'aw');
175
- if (!existsSync(gitTemplateDir)) return;
173
+ // 4. Remove git hooks (global core.hooksPath + legacy template)
174
+ function removeGitHooks(manifest) {
175
+ removeGlobalHooks();
176
176
 
177
- rmSync(gitTemplateDir, { recursive: true, force: true });
178
- try {
179
- const current = execSync('git config --global init.templateDir', {
180
- encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
181
- }).trim();
182
- if (current === gitTemplateDir) {
183
- execSync('git config --global --unset init.templateDir', { stdio: 'pipe' });
184
- }
185
- } catch { /* not set */ }
186
- fmt.logStep('Removed git template hook');
177
+ // Backward compat: also clean up old git template dir from previous versions
178
+ const legacyTemplateDir = manifest?.gitTemplate || join(HOME, '.git-templates', 'aw');
179
+ if (existsSync(legacyTemplateDir)) {
180
+ rmSync(legacyTemplateDir, { recursive: true, force: true });
181
+ try {
182
+ const current = execSync('git config --global init.templateDir', {
183
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
184
+ }).trim();
185
+ if (current === legacyTemplateDir) {
186
+ execSync('git config --global --unset init.templateDir', { stdio: 'pipe' });
187
+ }
188
+ } catch { /* not set */ }
189
+ fmt.logStep('Removed legacy git template hook');
190
+ }
187
191
  }
188
192
 
189
193
  // 5. Remove IDE auto-init tasks
@@ -242,12 +246,17 @@ export function nukeCommand(args) {
242
246
  // 3. Remove .aw_registry symlinks from ALL project directories
243
247
  removeProjectSymlinks();
244
248
 
245
- // 4. Remove git template hook
246
- removeGitTemplate(manifest);
249
+ // 4. Remove git hooks (core.hooksPath + legacy template)
250
+ removeGitHooks(manifest);
247
251
 
248
252
  // 5. Remove IDE auto-init tasks
249
253
  removeIdeTasks();
250
254
 
255
+ // 5b. Remove upgrade lock/log (inside .aw_registry, must happen before dir removal)
256
+ for (const p of [join(GLOBAL_AW_DIR, '.aw-upgrade.lock'), join(GLOBAL_AW_DIR, '.aw-upgrade.log')]) {
257
+ try { if (existsSync(p)) rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ }
258
+ }
259
+
251
260
  // 6. Remove ~/.aw_docs/
252
261
  const awDocs = join(HOME, '.aw_docs');
253
262
  if (existsSync(awDocs)) {
@@ -287,7 +296,7 @@ export function nukeCommand(args) {
287
296
  ` ${chalk.green('✓')} Generated files cleaned`,
288
297
  ` ${chalk.green('✓')} IDE symlinks cleaned`,
289
298
  ` ${chalk.green('✓')} Project symlinks cleaned`,
290
- ` ${chalk.green('✓')} Git template hook removed`,
299
+ ` ${chalk.green('✓')} Git hooks removed`,
291
300
  ` ${chalk.green('✓')} IDE auto-sync tasks removed`,
292
301
  ` ${chalk.green('✓')} Source of truth deleted`,
293
302
  '',
package/mcp.mjs CHANGED
@@ -84,10 +84,13 @@ function resolveGitHubToken() {
84
84
  }
85
85
  }
86
86
 
87
- // 3. No gh CLI — prompt manual setup
88
- fmt.logWarn('No GitHub token found — MCP auth will use ${GITHUB_TOKEN} env var interpolation');
89
- fmt.logWarn(' Install gh CLI: brew install gh && gh auth login');
90
- fmt.logWarn(' Or export: export GITHUB_TOKEN=ghp_xxx');
87
+ // 3. No token found anywhere
88
+ fmt.logWarn('No GitHub token found — MCP authentication will not work!');
89
+ fmt.logWarn('');
90
+ fmt.logWarn(' Fix (recommended): brew install gh && gh auth login');
91
+ fmt.logWarn(' Fix (manual): export GITHUB_TOKEN=ghp_xxx');
92
+ fmt.logWarn('');
93
+ fmt.logWarn(' Then re-run: aw init');
91
94
  return null;
92
95
  }
93
96
 
@@ -135,13 +138,6 @@ export function setupMcp(cwd, namespace) {
135
138
  headers: { Authorization: `Bearer ${ghToken || '${GITHUB_TOKEN}'}` },
136
139
  };
137
140
 
138
- // Server config with env var interpolation for committed project files
139
- const ghlAiServerProject = {
140
- type: 'http',
141
- url: mcpUrl,
142
- headers: { Authorization: 'Bearer ${GITHUB_TOKEN}' },
143
- };
144
-
145
141
  const gitJenkinsServer = paths.gitJenkinsPath
146
142
  ? { command: 'node', args: [paths.gitJenkinsPath] }
147
143
  : null;
@@ -157,7 +153,7 @@ export function setupMcp(cwd, namespace) {
157
153
 
158
154
  // ── Claude Code: .mcp.json (project root — committed, uses env var) ──
159
155
  const projectMcpPath = join(cwd, '.mcp.json');
160
- if (mergeJsonMcpServer(projectMcpPath, 'ghl-ai', ghlAiServerProject)) {
156
+ if (mergeJsonMcpServer(projectMcpPath, 'ghl-ai', ghlAiServerLocal)) {
161
157
  updatedFiles.push(projectMcpPath);
162
158
  }
163
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.30-beta.1",
3
+ "version": "0.1.30-beta.3",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
package/update.mjs CHANGED
@@ -2,18 +2,24 @@
2
2
  //
3
3
  // checkForUpdate() → fires npm view in background, returns { current, latest, hasUpdate }
4
4
  // notifyUpdate(r) → prints banner if r says update available
5
- // autoUpdate(r) → spawns npm install in background (--silent mode)
5
+ // autoUpdate(r) → synchronous install with atomic lock, verification, logging
6
6
  // promptUpdate(r) → asks user interactively (normal init)
7
7
 
8
- import { readFileSync } from 'node:fs';
9
- import { exec as execCb, execSync, spawn } from 'node:child_process';
8
+ import { readFileSync, mkdirSync, writeFileSync, statSync, rmSync, appendFileSync, existsSync } from 'node:fs';
9
+ import { exec as execCb, execSync } from 'node:child_process';
10
10
  import { promisify } from 'node:util';
11
+ import { join } from 'node:path';
12
+ import { homedir } from 'node:os';
11
13
  import * as fmt from './fmt.mjs';
12
14
  import { chalk } from './fmt.mjs';
13
15
 
14
16
  const exec = promisify(execCb);
15
17
 
16
18
  const PKG_NAME = '@ghl-ai/aw';
19
+ const LOCK_DIR = join(homedir(), '.aw_registry', '.aw-upgrade.lock');
20
+ const LOG_PATH = join(homedir(), '.aw_registry', '.aw-upgrade.log');
21
+ const LOCK_MAX_AGE_MS = 5 * 60 * 1000;
22
+ const LOG_MAX_ENTRIES = 50;
17
23
 
18
24
  function getLocalVersion() {
19
25
  try {
@@ -45,13 +51,66 @@ function compareVersions(a, b) {
45
51
  return 0;
46
52
  }
47
53
 
54
+ // ── Atomic lock via mkdir ──────────────────────────────────────────────
55
+ // mkdir is atomic on all POSIX filesystems. Stale detection uses
56
+ // directory mtime (OS-maintained) rather than parsing PIDs.
57
+
58
+ function acquireLock() {
59
+ try {
60
+ mkdirSync(LOCK_DIR);
61
+ writeFileSync(join(LOCK_DIR, 'pid'), String(process.pid));
62
+ return true;
63
+ } catch (e) {
64
+ if (e.code !== 'EEXIST') return false;
65
+ try {
66
+ const stat = statSync(LOCK_DIR);
67
+ if (Date.now() - stat.mtimeMs > LOCK_MAX_AGE_MS) {
68
+ rmSync(LOCK_DIR, { recursive: true, force: true });
69
+ return acquireLock();
70
+ }
71
+ } catch { /* stat failed — lock dir vanished, retry */ return acquireLock(); }
72
+ return false;
73
+ }
74
+ }
75
+
76
+ function releaseLock() {
77
+ try { rmSync(LOCK_DIR, { recursive: true, force: true }); } catch { /* best effort */ }
78
+ }
79
+
80
+ // ── Upgrade log ────────────────────────────────────────────────────────
81
+
82
+ function appendLog(entry) {
83
+ try {
84
+ const line = JSON.stringify(entry) + '\n';
85
+ appendFileSync(LOG_PATH, line);
86
+
87
+ const content = readFileSync(LOG_PATH, 'utf8');
88
+ const lines = content.trim().split('\n');
89
+ if (lines.length > LOG_MAX_ENTRIES) {
90
+ writeFileSync(LOG_PATH, lines.slice(-LOG_MAX_ENTRIES).join('\n') + '\n');
91
+ }
92
+ } catch { /* logging is best-effort */ }
93
+ }
94
+
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
+
48
107
  /**
49
- * Fetch the latest version from npm (non-blocking, 5s timeout).
108
+ * Fetch the latest version from npm (non-blocking, 15s timeout).
50
109
  * Call at the start of any command, await at the end.
51
110
  */
52
111
  export async function checkForUpdate() {
53
112
  try {
54
- const { stdout } = await exec(`npm view ${PKG_NAME} version`, { timeout: 5000 });
113
+ const { stdout } = await exec(`npm view ${PKG_NAME} version`, { timeout: 15_000 });
55
114
  const latest = stdout.trim();
56
115
  const current = getLocalVersion();
57
116
  return { current, latest, hasUpdate: compareVersions(latest, current) > 0 };
@@ -76,19 +135,29 @@ export function notifyUpdate(result) {
76
135
  }
77
136
 
78
137
  /**
79
- * Auto-update in background (fire-and-forget, detached process).
138
+ * Synchronous auto-update with atomic lock, verification, and logging.
80
139
  * Used by `aw init --silent`. Skipped for prerelease versions.
140
+ * @returns {{ status: string, reason?: string, from?: string, to?: string, error?: string }}
81
141
  */
82
142
  export function autoUpdate(result) {
83
- if (!result?.hasUpdate) return;
84
- if (isPrerelease(result.current)) return;
143
+ if (!result?.hasUpdate) return { status: 'skipped', reason: 'up-to-date' };
144
+ if (isPrerelease(result.current)) return { status: 'skipped', reason: 'prerelease' };
145
+ if (!acquireLock()) return { status: 'skipped', reason: 'locked' };
146
+
85
147
  try {
86
- const child = spawn('npm', ['i', '-g', `${PKG_NAME}@latest`], {
87
- detached: true,
88
- stdio: 'ignore',
89
- });
90
- child.unref();
91
- } catch { /* best effort */ }
148
+ execSync(`npm i -g ${PKG_NAME}@latest`, { stdio: 'pipe', timeout: 60_000 });
149
+ const installed = getInstalledVersion();
150
+ const ok = compareVersions(installed, result.current) > 0;
151
+ const entry = { ts: new Date().toISOString(), from: result.current, to: installed, ok };
152
+ appendLog(entry);
153
+ return { status: ok ? 'upgraded' : 'failed', ...entry };
154
+ } catch (e) {
155
+ const entry = { ts: new Date().toISOString(), from: result.current, error: e.message, ok: false };
156
+ appendLog(entry);
157
+ return { status: 'failed', error: e.message };
158
+ } finally {
159
+ releaseLock();
160
+ }
92
161
  }
93
162
 
94
163
  /**
@@ -115,9 +184,11 @@ export async function promptUpdate(result) {
115
184
  const s = fmt.spinner();
116
185
  s.start(`Updating to ${result.latest}...`);
117
186
  try {
118
- execSync(`npm i -g ${PKG_NAME}@latest`, { stdio: 'pipe', timeout: 30000 });
119
- s.stop(`Updated to ${chalk.green(result.latest)}`);
120
- return true;
187
+ execSync(`npm i -g ${PKG_NAME}@latest`, { stdio: 'pipe', timeout: 60_000 });
188
+ const installed = getInstalledVersion();
189
+ const ok = compareVersions(installed, result.current) > 0;
190
+ s.stop(ok ? `Updated to ${chalk.green(installed)}` : chalk.red('Update may have failed — verify with: aw --version'));
191
+ return ok;
121
192
  } catch {
122
193
  s.stop(chalk.red('Update failed'));
123
194
  fmt.logWarn(`Manual update: ${chalk.bold(`npm i -g ${PKG_NAME}@latest`)}`);