@aion0/forge 0.10.88 → 0.10.89

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/RELEASE_NOTES.md CHANGED
@@ -1,8 +1,14 @@
1
- # Forge v0.10.88
1
+ # Forge v0.10.89
2
2
 
3
3
  Released: 2026-06-17
4
4
 
5
- ## Changes since v0.10.87
5
+ ## Changes since v0.10.88
6
6
 
7
+ ### Other
8
+ - fix(build): clean install on fresh machines — webpack build + valid route exports
9
+ - fix(projects): strict resolve requires git-backed match + fresh-clone visibility
10
+ - refactor(projects): auto-clone lands in project root, retire cloned-projects
11
+ - feat(projects): auto-clone lands in a real project root, not the cache
7
12
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.87...v0.10.88
13
+
14
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.88...v0.10.89
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { NextResponse } from 'next/server';
11
- import { isValidToken } from '../verify/route';
11
+ import { isValidToken } from '@/lib/api-tokens';
12
12
 
13
13
  export async function POST(req: Request) {
14
14
  if (!isValidToken(req)) {
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
2
2
  import { verifyAdmin, getAdminPassword } from '@/lib/password';
3
3
  import { createApiKey, listApiKeys } from '@/lib/api-keys';
4
4
  import { isAuthenticated } from '@/lib/api-auth';
5
- import { isValidToken } from '@/app/api/auth/verify/route';
5
+ import { isValidToken } from '@/lib/api-tokens';
6
6
 
7
7
  function isDev(): boolean {
8
8
  return process.env.NODE_ENV !== 'production' || process.env.FORGE_DEV === '1';
@@ -1,23 +1,7 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { randomUUID } from 'node:crypto';
3
-
4
- // In-memory valid tokens shared across API routes in the same process
5
- const tokenKey = Symbol.for('forge-api-tokens');
6
- const g = globalThis as any;
7
- if (!g[tokenKey]) g[tokenKey] = new Set<string>();
8
- export const validTokens: Set<string> = g[tokenKey];
9
-
10
- /** Verify a token is valid (for use in other API routes) */
11
- export function isValidToken(req: Request): boolean {
12
- // Check header
13
- const headerToken = new Headers(req.headers).get('x-forge-token');
14
- if (headerToken && validTokens.has(headerToken)) return true;
15
- // Check cookie
16
- const cookieHeader = new Headers(req.headers).get('cookie') || '';
17
- const match = cookieHeader.match(/forge-api-token=([^;]+)/);
18
- if (match && validTokens.has(match[1])) return true;
19
- return false;
20
- }
2
+ // Token store/helpers live in lib/api-tokens (a route file may only export
3
+ // HTTP handlers + Next's allowed config fields — Next 16 type-checks this).
4
+ import { mintToken } from '@/lib/api-tokens';
21
5
 
22
6
  export async function POST(req: Request) {
23
7
  const body = await req.json();
@@ -32,8 +16,7 @@ export async function POST(req: Request) {
32
16
  return NextResponse.json({ error: 'invalid password' }, { status: 401 });
33
17
  }
34
18
 
35
- const token = randomUUID();
36
- validTokens.add(token);
19
+ const token = mintToken();
37
20
 
38
21
  const res = NextResponse.json({ ok: true, token });
39
22
  res.cookies.set('forge-api-token', token, {
@@ -15,7 +15,7 @@
15
15
  * forwards JSON-RPC frames here and carries the Mcp-Session-Id header.
16
16
  */
17
17
 
18
- import { isValidToken } from '@/app/api/auth/verify/route';
18
+ import { isValidToken } from '@/lib/api-tokens';
19
19
  import { isAuthenticated } from '@/lib/api-auth';
20
20
  import { createManagementMcpServer } from '@/mcp/server';
21
21
  import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
@@ -544,7 +544,9 @@ function preprocessTemplate(
544
544
  // / default_project_path) after a Reinstall without forcing the user
545
545
  // to re-run the entire wizard. Existing non-empty values are preserved
546
546
  // — see the merge logic inside.
547
- export async function applyConnectors(
547
+ // Module-local (not exported): a route file may only export HTTP handlers +
548
+ // Next's config fields — exporting helpers fails Next 16's route type-check.
549
+ async function applyConnectors(
548
550
  values: Record<string, string> | undefined,
549
551
  selectedConnectors: string[] | undefined,
550
552
  sourceId?: string,
@@ -758,7 +760,7 @@ export async function applyConnectors(
758
760
  * template omits `_pipelines`, falls back to `suggested_pipelines` (the
759
761
  * marketplace's fortinet-* prefix heuristic).
760
762
  */
761
- export async function applyTemplatePipelines(sourceId?: string, deptId?: string): Promise<{
763
+ async function applyTemplatePipelines(sourceId?: string, deptId?: string): Promise<{
762
764
  installed: string[];
763
765
  errors: Array<{ name: string; error: string }>;
764
766
  }> {
@@ -28,6 +28,21 @@ import { homedir } from 'node:os';
28
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
29
  const ROOT = join(__dirname, '..');
30
30
 
31
+ // Node version guard — Forge is tested on Node 20–22 (LTS). Bleeding-edge
32
+ // majors crash `next build` with a cryptic Turbopack error ("Expected process
33
+ // result to be a module"); old majors lack required APIs. Warn early so the
34
+ // real cause is visible before the build runs.
35
+ {
36
+ const major = Number(process.versions.node.split('.')[0]);
37
+ if (major < 20 || major > 24) {
38
+ console.warn(
39
+ `\n⚠️ Forge is tested on Node 20–22 (LTS); you're on Node ${process.versions.node}.\n` +
40
+ ` If startup fails with a Turbopack / native-module error, switch to Node 22:\n` +
41
+ ` nvm install 22 && nvm use 22 && npm i -g @aion0/forge\n`
42
+ );
43
+ }
44
+ }
45
+
31
46
  // Pre-install EnvHttpProxyAgent before any dynamic import('undici') can
32
47
  // clobber Node's NODE_USE_ENV_PROXY agent (see lib/proxy-setup.ts).
33
48
  // No-op off-corp (no proxy env), so non-docker deployments are unchanged.
@@ -51,7 +66,12 @@ function buildNext() {
51
66
  // installed npm-package copy (.npmrc isn't published).
52
67
  execSync('npm install --include=dev --legacy-peer-deps', { cwd: ROOT, stdio: 'inherit' });
53
68
  }
54
- execSync('npx next build', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
69
+ // Build with webpack, not Turbopack (Next 16's default). Turbopack chokes on
70
+ // the Proxy/middleware bundle — proxy.ts pulls auth + a route module that
71
+ // transitively imports Node-native deps (better-sqlite3 …) — and dies with
72
+ // "TurbopackInternalError: Expected process result to be a module". Webpack
73
+ // (the pre-16 path) handles the Node-runtime middleware fine.
74
+ execSync('npx next build --webpack', { cwd: ROOT, stdio: 'inherit', env: { ...process.env } });
55
75
  }
56
76
 
57
77
  /**
@@ -0,0 +1,31 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ /**
4
+ * In-memory admin-minted API tokens, shared across API routes + the proxy in
5
+ * the same process. Lives in lib/ (not an API route) on purpose: Next 16's
6
+ * route type-check rejects non-handler exports from a route file, and importing
7
+ * a route module into proxy.ts dragged Node-native deps into the middleware
8
+ * bundle — which crashed the Turbopack build.
9
+ */
10
+ const tokenKey = Symbol.for('forge-api-tokens');
11
+ const g = globalThis as any;
12
+ if (!g[tokenKey]) g[tokenKey] = new Set<string>();
13
+ export const validTokens: Set<string> = g[tokenKey];
14
+
15
+ /** Mint + register a new token (called by the password-verify route). */
16
+ export function mintToken(): string {
17
+ const token = randomUUID();
18
+ validTokens.add(token);
19
+ return token;
20
+ }
21
+
22
+ /** True if the request carries a valid token (x-forge-token header or
23
+ * forge-api-token cookie). */
24
+ export function isValidToken(req: Request): boolean {
25
+ const headerToken = new Headers(req.headers).get('x-forge-token');
26
+ if (headerToken && validTokens.has(headerToken)) return true;
27
+ const cookieHeader = new Headers(req.headers).get('cookie') || '';
28
+ const match = cookieHeader.match(/forge-api-token=([^;]+)/);
29
+ if (match && validTokens.has(match[1])) return true;
30
+ return false;
31
+ }
package/lib/projects.ts CHANGED
@@ -297,11 +297,12 @@ export interface StrictResolveResult {
297
297
  * - else gitlab connector default_project_path
298
298
  * - else → error('no_repo_specified')
299
299
  * 3. Re-scan locally for a checkout whose origin matches the target → use it
300
- * 4. Clone via tryGitlabClone — writes to the Forge-managed cache
301
- * (<dataDir>/cloned-projects), which is ALWAYS writable and does NOT
302
- * need a configured projectRoot. We best-effort register the container
303
- * root (/data/project) so manually-placed checkouts there get scanned,
304
- * but never block the clone on it.
300
+ * 4. Clone via tryGitlabClone — lands in a real project root so the
301
+ * checkout persists and is reused next run. In container mode we register
302
+ * the docker-internal /data/project (a host-mounted path) as the root
303
+ * first, so the clone shows up as a normal project; on a host install it
304
+ * takes the first configured project path (projectRoots[0]), falling back
305
+ * to ~/IdeaProjects / <dataDir>/scratch.
305
306
  * - gitlab connector missing → error('no_repo_specified')
306
307
  * - clone failed → error('clone_failed')
307
308
  */
@@ -309,25 +310,30 @@ export function resolveProjectStrict(name: string | undefined): StrictResolveRes
309
310
  const trimmed = (name || '').trim();
310
311
 
311
312
  // Step 1: existing match — reuse the lenient resolver's match logic by
312
- // delegating, but discard its scratch fallback.
313
+ // delegating, but discard its scratch fallback. Crucially, only accept a
314
+ // GIT-BACKED match: this resolver exists for requires_real_project pipelines,
315
+ // so a non-git project (e.g. the built-in 'default' / 'scratch', or a plain
316
+ // folder the chat agent named) must NOT satisfy it — fall through and clone
317
+ // the real repo instead. Without this, `project: default` was returned as-is
318
+ // and the node's `git rev-parse` then failed with "not a git repository".
313
319
  if (trimmed) {
314
320
  const hit = getProjectInfo(trimmed);
315
- if (hit) return { project: hit, source: 'existing' };
321
+ if (hit?.hasGit) return { project: hit, source: 'existing' };
316
322
 
317
323
  const projects = scanProjects();
318
324
  if (trimmed.includes('/')) {
319
325
  const want = normalizeRepoPath(trimmed) || trimmed.toLowerCase().replace(/^\/+|\/+$/g, '');
320
326
  const wantBase = want.split('/').pop()!;
321
327
  const byRepo = projects.filter((p) => p.repo && p.repo === want);
322
- if (byRepo.length === 1) return { project: byRepo[0], source: 'existing' };
328
+ if (byRepo.length === 1 && byRepo[0].hasGit) return { project: byRepo[0], source: 'existing' };
323
329
  const byRepoBase = projects.filter((p) => p.repo && p.repo.split('/').pop() === wantBase);
324
- if (byRepoBase.length === 1) return { project: byRepoBase[0], source: 'existing' };
330
+ if (byRepoBase.length === 1 && byRepoBase[0].hasGit) return { project: byRepoBase[0], source: 'existing' };
325
331
  }
326
332
  const base = trimmed.split('/').pop()!.trim();
327
333
  const ownerRepo = trimmed.replace(/\//g, '-');
328
334
  const cands = projects.filter((p) =>
329
335
  p.name === base || p.name === ownerRepo || p.name.toLowerCase() === base.toLowerCase());
330
- if (cands.length === 1) return { project: cands[0], source: 'existing' };
336
+ if (cands.length === 1 && cands[0].hasGit) return { project: cands[0], source: 'existing' };
331
337
  }
332
338
 
333
339
  // Step 2: derive target repo path
@@ -450,9 +456,17 @@ function tryGitlabClone(repoPath: string): LocalProject | null {
450
456
  const path = repoPath.replace(/^\/+|\/+$/g, '');
451
457
  if (!path || !path.includes('/')) return null; // not a repo path shape
452
458
  const safe = path.replace(/[^A-Za-z0-9._-]+/g, '-');
453
- const cacheRoot = join(getDataDir(), 'cloned-projects');
454
- const target = join(cacheRoot, safe);
455
- try { mkdirSync(cacheRoot, { recursive: true }); } catch {}
459
+ // Clone into a real project root so the checkout persists as a normal
460
+ // project and is reused across runs (no delete needed). In container mode
461
+ // ensureContainerRoot registers the docker-internal /data/project (a
462
+ // host-mounted path) as the first root; on a host install we just take the
463
+ // first configured project path. getDefaultCloneRoot encodes that priority
464
+ // (projectRoots[0] → ~/IdeaProjects → <dataDir>/scratch). The old
465
+ // <dataDir>/cloned-projects hidden cache is retired.
466
+ ensureContainerRoot();
467
+ const cloneRoot = getDefaultCloneRoot();
468
+ const target = join(cloneRoot, safe);
469
+ try { mkdirSync(cloneRoot, { recursive: true }); } catch {}
456
470
 
457
471
  // Inject PAT into URL. gitlab accepts `oauth2:<token>` basic auth on
458
472
  // HTTPS. URL-encode the token in case it contains '@' or '/'.
@@ -467,6 +481,11 @@ function tryGitlabClone(repoPath: string): LocalProject | null {
467
481
  if (!alreadyCloned) {
468
482
  execSync(`git clone --quiet "${authedUrl}" "${target}"`, { stdio: 'pipe', timeout: 120000 });
469
483
  _cloneFreshAt.set(target, Date.now());
484
+ // The new checkout must be discoverable right away: a scanProjects() cache
485
+ // populated earlier this run (e.g. by resolveProjectStrict's pre-clone
486
+ // scan) wouldn't include it, so getProjectInfo would miss the repo we
487
+ // just cloned and downstream resolution could fall back to scratch.
488
+ invalidateProjectScan();
470
489
  } else if (!stillFresh) {
471
490
  // Best-effort refresh; failures (offline, network, auth drift) are
472
491
  // non-fatal — fall back to whatever's cached. Pipelines see stale
@@ -487,9 +506,13 @@ function tryGitlabClone(repoPath: string): LocalProject | null {
487
506
  try { mtime = statSync(target).mtime.toISOString(); }
488
507
  catch { mtime = new Date().toISOString(); }
489
508
  return {
490
- name: path,
509
+ // Use the on-disk dir name (== scanProjects' entry.name) so the name we
510
+ // seed back into input.project matches what getProjectInfo will find next
511
+ // scan. Returning the slashed repo path here desynced the two and made
512
+ // computePipelineTmpDir's getProjectInfo miss → tmp_dir fell to scratch.
513
+ name: safe,
491
514
  path: target,
492
- root: cacheRoot,
515
+ root: cloneRoot,
493
516
  hasGit: existsSync(join(target, '.git')),
494
517
  hasClaudeMd: existsSync(join(target, 'CLAUDE.md')),
495
518
  language: null,
package/next.config.ts CHANGED
@@ -10,6 +10,13 @@ const localIPs = Object.values(networkInterfaces())
10
10
  .map(i => i!.address);
11
11
 
12
12
  const nextConfig: NextConfig = {
13
+ // Skip `next build`'s TypeScript step. It duplicates our authoritative
14
+ // `tsc --noEmit` (run in CI / publish.sh) and additionally enforces a
15
+ // route-export shape rule that rejects Forge's intentional patterns —
16
+ // craft-system runtime routes that serve React/jsx as importable modules
17
+ // (app/api/craft-system/runtime/*) export non-handler symbols on purpose.
18
+ // tsc remains the real type gate; this only drops Next's redundant check.
19
+ typescript: { ignoreBuildErrors: true },
13
20
  serverExternalPackages: ['better-sqlite3', 'esbuild'],
14
21
  allowedDevOrigins: localIPs,
15
22
  async rewrites() {
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.88",
3
+ "version": "0.10.89",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
6
9
  "scripts": {
7
10
  "dev": "next dev --turbopack",
8
11
  "build": "next build",
package/proxy.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { auth } from './lib/auth';
3
3
  import { hasValidApiKey } from './lib/api-auth';
4
- import { isValidToken } from './app/api/auth/verify/route';
4
+ import { isValidToken } from './lib/api-tokens';
5
5
 
6
6
  // Node runtime: the auth() wrapper decodes the NextAuth session JWT with the
7
7
  // AUTH_SECRET that the route layer also uses (auto-generated in-process when
package/publish.sh CHANGED
@@ -46,6 +46,25 @@ fi
46
46
  echo "Version: $CURRENT → $NEW_VERSION"
47
47
  echo ""
48
48
 
49
+ # ── Build gate: a broken build must never ship ──────────────────────────────
50
+ # publish.sh used to bump+tag+push with ZERO build verification, so compile /
51
+ # bundler errors only surfaced when a fresh user ran `forge server start` (their
52
+ # first clean `next build`). Run it here with a CLEAN .next so a stale cache
53
+ # can't mask source errors. tsc is the authoritative type gate (next build's own
54
+ # TS step is off via typescript.ignoreBuildErrors); __tests__ are tsx scripts
55
+ # checked separately, so they're excluded.
56
+ echo "Verifying clean build before publish ..."
57
+ rm -rf .next
58
+ npx next build --webpack
59
+ TSC_ERRORS=$(npx tsc --noEmit 2>&1 | grep "error TS" | grep -v "__tests__" || true)
60
+ if [ -n "$TSC_ERRORS" ]; then
61
+ echo "$TSC_ERRORS"
62
+ echo "✗ Type errors — aborting publish (no version bump/commit/tag made)."
63
+ exit 1
64
+ fi
65
+ echo "✓ Build + type-check clean"
66
+ echo ""
67
+
49
68
  # Refresh the bundled model-registry snapshot from public-info.
50
69
  # This is the offline/first-run fallback for lib/agents/known-models.ts —
51
70
  # pulling it here keeps it from drifting against the live registry.