@4ge/cli 0.2.1 → 0.2.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/dist/base.js CHANGED
@@ -177,21 +177,47 @@ export class BaseCommand extends Command {
177
177
  },
178
178
  }, null, 2));
179
179
  }
180
+ /**
181
+ * Render an error respecting the active output mode.
182
+ *
183
+ * - JSON mode: emit the structured `{ data, error, metadata }` envelope.
184
+ * - Pretty mode: render a human-readable error (never a JSON blob) using
185
+ * oclif's `error()`, which pretty-prints to stderr and exits non-zero.
186
+ *
187
+ * Centralising the mode branch here means command authors can call
188
+ * `outputErrorAndExit({ message, code, hint })` without guarding on
189
+ * `isJsonMode` themselves — eliminating a class of bug where pretty-mode
190
+ * users saw raw JSON error envelopes.
191
+ */
180
192
  outputError(error, metadata) {
181
193
  const errorObj = typeof error === 'string'
182
194
  ? { message: error }
183
195
  : error;
184
- this.log(JSON.stringify({
185
- data: null,
186
- error: errorObj,
187
- metadata: {
188
- timestamp: new Date().toISOString(),
189
- ...metadata,
190
- },
191
- }, null, 2));
196
+ if (this.isJsonMode) {
197
+ this.log(JSON.stringify({
198
+ data: null,
199
+ error: errorObj,
200
+ metadata: {
201
+ timestamp: new Date().toISOString(),
202
+ ...metadata,
203
+ },
204
+ }, null, 2));
205
+ return;
206
+ }
207
+ // Pretty mode — mirror the human format used by handleError():
208
+ // <message>
209
+ // 💡 <hint>
210
+ // oclif's error() throws a PrettyPrintableError caught by the framework;
211
+ // it never returns, so callers chained off outputError() (e.g.
212
+ // outputErrorAndExit) reliably terminate.
213
+ const lines = [errorObj.message];
214
+ if (errorObj.hint)
215
+ lines.push(`💡 ${errorObj.hint}`);
216
+ this.error(lines.join('\n'));
192
217
  }
193
218
  outputErrorAndExit(error, metadata) {
194
219
  this.outputError(error, metadata);
220
+ // Only reached in JSON mode — in pretty mode outputError() throws.
195
221
  this.exit(1);
196
222
  }
197
223
  /**
@@ -3,6 +3,8 @@ import { resolveCredential } from '../../core/auth/resolution.js';
3
3
  import { createApiClient, ConnectivityError } from '../../core/auth/api-client.js';
4
4
  import { ensureFreshToken } from '../../core/auth/token-refresh.js';
5
5
  import { cliAuthStatus } from '../../generated/api/sdk.gen.js';
6
+ import { zCliAuthStatusResponse } from '../../generated/api/zod.gen.js';
7
+ import { validateResponse } from '../../core/api/index.js';
6
8
  export default class AuthStatus extends BaseCommand {
7
9
  static description = 'Check authentication status';
8
10
  async run() {
@@ -85,8 +87,15 @@ export default class AuthStatus extends BaseCommand {
85
87
  }
86
88
  return;
87
89
  }
90
+ // Validate the envelope against the generated schema and extract the
91
+ // typed user. Uses the same boundary-validation seam as every other
92
+ // command (the generated zCliAuthStatusResponse now types data.user,
93
+ // data.workspaces, and the authenticated_as enum in lockstep with the
94
+ // app's CLIAuthStatusSchema — see docs/cli-api-contract.pin.md).
95
+ const validated = validateResponse(data, null, zCliAuthStatusResponse);
96
+ const user = validated.data?.user;
88
97
  if (this.isJsonMode) {
89
- this.outputJson({ source: activeCredential.source, profile: activeCredential.profile, user: data });
98
+ this.outputJson({ source: activeCredential.source, profile: activeCredential.profile, user });
90
99
  }
91
100
  else {
92
101
  if (activeCredential.source === 'environment') {
@@ -96,8 +105,9 @@ export default class AuthStatus extends BaseCommand {
96
105
  this.log(`Source: ${activeCredential.source}`);
97
106
  if (activeCredential.profile)
98
107
  this.log(`Profile: ${activeCredential.profile}`);
99
- if (data)
100
- this.log(`User: ${data.email || data.id || 'Unknown'}`);
108
+ // Display precedence per contract §5.8: full_name → username → email → id.
109
+ if (user)
110
+ this.log(`User: ${user.full_name || user.username || user.email || user.id || 'Unknown'}`);
101
111
  }
102
112
  }
103
113
  catch (error) {
@@ -66,17 +66,18 @@ export async function subscribeForTokens(opts) {
66
66
  };
67
67
  }
68
68
  /**
69
- * Get the Supabase URL and anon key from environment variables.
70
- * Throws if either is missing.
69
+ * Get the Supabase URL and anon key.
70
+ *
71
+ * The anon key is public by design (RLS enforces all security), so the
72
+ * production values are baked in as defaults — a globally-installed CLI has
73
+ * no `.env.local` and must still support the OAuth browser flow with zero
74
+ * user configuration. `SUPABASE_URL` / `SUPABASE_ANON_KEY` env vars override
75
+ * the defaults (used by `4ge/app` for local dev against a local Supabase).
71
76
  */
72
77
  export function getSupabaseConfig() {
73
- const supabaseUrl = process.env.SUPABASE_URL;
74
- const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
75
- if (!supabaseUrl) {
76
- throw new Error('SUPABASE_URL environment variable is required for OAuth login. Set it in your .env file or environment.');
77
- }
78
- if (!supabaseAnonKey) {
79
- throw new Error('SUPABASE_ANON_KEY environment variable is required for OAuth login. Set it in your .env file or environment.');
80
- }
78
+ // Production Supabase project backing app.4ge.dev.
79
+ const supabaseUrl = process.env.SUPABASE_URL || 'https://ujhqusbkvaaczncgzsyo.supabase.co';
80
+ const supabaseAnonKey = process.env.SUPABASE_ANON_KEY ||
81
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InVqaHF1c2JrdmFhY3puY2d6c3lvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTU4NTQ1OTAsImV4cCI6MjA3MTQzMDU5MH0.A0c0oG-Dj0EvEuH8eNOa8y0o81uCNPkjlOFqoUMx170';
81
82
  return { supabaseUrl, supabaseAnonKey };
82
83
  }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Best-effort npm registry update check for the 4ge CLI.
3
+ *
4
+ * Design constraints:
5
+ * - Never throws. A failed/timeout/missing registry response is a no-op.
6
+ * - Never blocks the user: hits the registry at most once per `cacheTtlMs`
7
+ * (default 24h), persisting the result in a cache file alongside the
8
+ * credentials directory.
9
+ * - Silent in machine/JSON contexts — the caller (the postrun hook) is
10
+ * responsible for suppressing output in `--json` mode.
11
+ */
12
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
13
+ import { dirname, join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ export const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
16
+ export const DEFAULT_REGISTRY_TIMEOUT_MS = 3_000;
17
+ export const NPM_REGISTRY_URL = 'https://registry.npmjs.org';
18
+ export const PACKAGE_NAME = '@4ge/cli';
19
+ /**
20
+ * Default cache path: `~/.4ge/.update-check.json` (sibling of credentials.json).
21
+ */
22
+ export function getDefaultCachePath() {
23
+ return join(homedir(), '.4ge', '.update-check.json');
24
+ }
25
+ /**
26
+ * Compare two semver strings by their numeric major.minor.patch components.
27
+ * Prerelease suffixes are ignored (treated as equal to the release).
28
+ *
29
+ * @returns -1 if `a < b`, 0 if equal, 1 if `a > b`. Non-parseable parts are
30
+ * treated as 0.
31
+ */
32
+ export function compareVersions(a, b) {
33
+ const pa = parseSemver(a);
34
+ const pb = parseSemver(b);
35
+ for (let i = 0; i < 3; i++) {
36
+ const x = pa[i];
37
+ const y = pb[i];
38
+ if (x < y)
39
+ return -1;
40
+ if (x > y)
41
+ return 1;
42
+ }
43
+ return 0;
44
+ }
45
+ function parseSemver(v) {
46
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(v ?? '');
47
+ return [
48
+ m ? Number(m[1]) : 0,
49
+ m ? Number(m[2]) : 0,
50
+ m ? Number(m[3]) : 0,
51
+ ];
52
+ }
53
+ /**
54
+ * Check the npm registry for a newer published version of the package.
55
+ *
56
+ * @returns an {@link UpdateInfo} when an update is available, otherwise `null`.
57
+ * Always resolves (never rejects) — safe to call fire-and-forget.
58
+ */
59
+ export async function checkForUpdate(opts) {
60
+ const { currentVersion, packageName = PACKAGE_NAME, cachePath = getDefaultCachePath(), cacheTtlMs = DEFAULT_CACHE_TTL_MS, timeoutMs = DEFAULT_REGISTRY_TIMEOUT_MS, registryUrl = NPM_REGISTRY_URL, fetchImpl = globalThis.fetch, } = opts;
61
+ if (!currentVersion)
62
+ return null;
63
+ // 1. Try the cache first.
64
+ let cached = null;
65
+ try {
66
+ const raw = await readFile(cachePath, 'utf-8');
67
+ cached = JSON.parse(raw);
68
+ }
69
+ catch {
70
+ // Missing or corrupt cache is fine — fall through to a live fetch.
71
+ cached = null;
72
+ }
73
+ const now = Date.now();
74
+ // "Fresh" means we checked recently, regardless of whether we learned a
75
+ // version. This gives us negative caching: a failed/offline check is retried
76
+ // at most once per TTL window instead of adding a network timeout to every
77
+ // subsequent command.
78
+ const cacheFresh = cached && typeof cached.lastCheck === 'number' &&
79
+ (now - cached.lastCheck) < cacheTtlMs;
80
+ let latest;
81
+ if (cacheFresh) {
82
+ // Either reuse a cached version, or (if the last check found nothing)
83
+ // honour the negative cache and skip a redundant fetch.
84
+ latest = cached.latest;
85
+ }
86
+ else {
87
+ latest = await fetchLatestVersion(packageName, registryUrl, timeoutMs, fetchImpl);
88
+ // Persist for next time — including a negative cache entry when fetch
89
+ // returned nothing, so we don't hammer the registry while offline.
90
+ await safeWriteCache(cachePath, { lastCheck: now, latest });
91
+ if (!latest)
92
+ return null;
93
+ }
94
+ if (!latest)
95
+ return null;
96
+ const updateAvailable = compareVersions(currentVersion, latest) < 0;
97
+ if (!updateAvailable)
98
+ return null;
99
+ return { current: currentVersion, latest, updateAvailable };
100
+ }
101
+ async function fetchLatestVersion(packageName, registryUrl, timeoutMs, fetchImpl) {
102
+ if (typeof fetchImpl !== 'function')
103
+ return undefined;
104
+ const encoded = encodeURIComponent(packageName);
105
+ const url = `${registryUrl}/${encoded}`;
106
+ const controller = new AbortController();
107
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
108
+ try {
109
+ const res = await fetchImpl(url, {
110
+ signal: controller.signal,
111
+ headers: { accept: 'application/json' },
112
+ });
113
+ if (!res.ok)
114
+ return undefined;
115
+ const body = (await res.json());
116
+ return body?.['dist-tags']?.latest;
117
+ }
118
+ catch {
119
+ // Network error, timeout, malformed JSON — all treated as "no info".
120
+ return undefined;
121
+ }
122
+ finally {
123
+ clearTimeout(timer);
124
+ }
125
+ }
126
+ async function safeWriteCache(cachePath, data) {
127
+ try {
128
+ await mkdir(dirname(cachePath), { recursive: true });
129
+ await writeFile(cachePath, JSON.stringify(data), 'utf-8');
130
+ }
131
+ catch {
132
+ // Cache write is best-effort; never propagate.
133
+ }
134
+ }
@@ -57,7 +57,20 @@ export const zCliAuthCallbackResponse = z.object({
57
57
  * Successful response
58
58
  */
59
59
  export const zCliAuthStatusResponse = z.object({
60
- data: z.unknown(),
60
+ data: z.object({
61
+ user: z.object({
62
+ id: z.uuid(),
63
+ email: z.email().nullable(),
64
+ full_name: z.string().nullish(),
65
+ username: z.string().nullish(),
66
+ }),
67
+ workspaces: z.array(z.object({
68
+ id: z.uuid(),
69
+ name: z.string(),
70
+ role: z.enum(["OWNER", "MEMBER"]),
71
+ })),
72
+ authenticated_as: z.enum(["session", "api_key"]),
73
+ }),
61
74
  error: z.unknown(),
62
75
  metadata: z.object({
63
76
  timestamp: z.string(),
@@ -0,0 +1,25 @@
1
+ import { checkForUpdate } from '../core/version-check.js';
2
+ const hook = async (options) => {
3
+ const { argv, config } = options;
4
+ // Silence in JSON / machine mode so stdout stays clean.
5
+ if (argv.includes('--json') || argv.includes('-j'))
6
+ return;
7
+ // Unlikely for a published CLI, but don't attempt without a version.
8
+ const currentVersion = config.version;
9
+ if (!currentVersion)
10
+ return;
11
+ try {
12
+ const info = await checkForUpdate({ currentVersion });
13
+ if (info?.updateAvailable) {
14
+ // Write directly to stderr — oclif's context.warn() also emits a stack
15
+ // trace via the logger, which is noisy for an advisory notice.
16
+ const chalk = (await import('chalk')).default;
17
+ process.stderr.write(chalk.yellow(`\nUpdate available: ${info.current} → ${info.latest}\n` +
18
+ ` Run \`npm i -g @4ge/cli\` to update.\n`));
19
+ }
20
+ }
21
+ catch {
22
+ // Never let an update check interfere with the user's command.
23
+ }
24
+ };
25
+ export default hook;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4ge/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "4ge Service Architecture Bridge & CLI",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -26,6 +26,9 @@
26
26
  "plugins": [
27
27
  "@oclif/plugin-help"
28
28
  ],
29
+ "hooks": {
30
+ "postrun": "./dist/hooks/postrun.js"
31
+ },
29
32
  "flexibleTaxonomy": true
30
33
  },
31
34
  "type": "module",
package/skills/4ge-cli.md CHANGED
@@ -1,3 +1,19 @@
1
+ ---
2
+ name: 4ge-cli
3
+ description: >
4
+ Instructions for AI agents to interact with the 4ge Product Management
5
+ Platform via the `4ge` CLI. Covers command syntax (namespace:command with a
6
+ colon), mandatory `--json` agent mode, the response envelope, error handling,
7
+ discovery, dry-run previews, retry, and the full command reference for the
8
+ project / epic / story / feature / flow / idea / template / plan / auth /
9
+ config / skill namespaces. Use when an agent needs to read or mutate 4ge plan
10
+ assets, work with pull/list/show/update commands, or install the 4ge CLI skill
11
+ for an agent harness.
12
+ user-invocable: true
13
+ risk: safe
14
+ source: https://github.com/Conflate-AI/4ge-cli/blob/main/skills/4ge-cli.md
15
+ ---
16
+
1
17
  # 4ge CLI Skill
2
18
 
3
19
  This document provides instructions for AI agents to interact with the 4ge