@descix/cli 1.0.1 → 1.0.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.
Files changed (100) hide show
  1. package/LICENSE.md +671 -0
  2. package/README.md +43 -4
  3. package/agent-assets/guides/README.md +2 -2
  4. package/agent-assets/guides/dynamic-site-pattern.md +29 -14
  5. package/agent-assets/guides/knowledge-only-pattern.md +5 -5
  6. package/agent-assets/guides/microservice-pattern.md +48 -4
  7. package/agent-assets/guides/static-site-pattern.md +8 -0
  8. package/agent-assets/instructions/app-asset-validation.md +5 -5
  9. package/agent-assets/instructions/onboarding-flow.md +4 -4
  10. package/agent-assets/instructions/standard-folder-structure.md +20 -11
  11. package/agent-assets/reference/app-creation.md +42 -20
  12. package/agent-assets/reference/architecture.md +24 -22
  13. package/agent-assets/reference/local-dev.md +230 -67
  14. package/agent-assets/reference/mcp-integration.md +9 -9
  15. package/agent-assets/reference/platform-api-consumer.md +244 -0
  16. package/agent-assets/reference/workspace-config.md +7 -7
  17. package/agent-assets/rules/descix_mcp.template.mdc +8 -8
  18. package/bin/descix.js +2559 -1435
  19. package/bin/kb-deny-lint.mjs +91 -0
  20. package/bin/mcp-server.js +175 -91
  21. package/lib/agent-files.js +37 -25
  22. package/lib/api-client.js +223 -56
  23. package/lib/auth-guard.js +13 -3
  24. package/lib/chat-session.js +187 -0
  25. package/lib/cli-version.js +59 -0
  26. package/lib/commands/airdrop.js +251 -0
  27. package/lib/commands/auth.js +254 -72
  28. package/lib/commands/briefer/errors.js +88 -0
  29. package/lib/commands/briefer/index.js +319 -0
  30. package/lib/commands/briefer/sources/canonical-sources.js +129 -0
  31. package/lib/commands/briefer/sources/entitlements.js +209 -0
  32. package/lib/commands/briefer/sources/environments.js +205 -0
  33. package/lib/commands/briefer/sources/identifiers.js +214 -0
  34. package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
  35. package/lib/commands/briefer/sources/routing.js +275 -0
  36. package/lib/commands/briefer/sources/what-is-not.js +223 -0
  37. package/lib/commands/briefer/stitcher.js +134 -0
  38. package/lib/commands/briefer/util/source-reader.js +502 -0
  39. package/lib/commands/config.js +210 -90
  40. package/lib/commands/corpus.js +978 -0
  41. package/lib/commands/credits.js +122 -0
  42. package/lib/commands/health.js +627 -0
  43. package/lib/commands/kb-list-render.js +32 -0
  44. package/lib/commands/kb.js +190 -413
  45. package/lib/commands/mcp.js +5 -5
  46. package/lib/commands/microservice-restart.js +359 -0
  47. package/lib/commands/model-config.js +313 -0
  48. package/lib/commands/retired-kb-sync.js +134 -0
  49. package/lib/commands/serve.js +26 -1
  50. package/lib/commands/status.js +23 -51
  51. package/lib/commands/update.js +36 -107
  52. package/lib/core/Chunker.js +491 -17
  53. package/lib/core/CorpusDenyLint.js +326 -0
  54. package/lib/core/CorpusWalker.js +357 -0
  55. package/lib/core/Hydrator.js +75 -29
  56. package/lib/core/ManifestLoader.js +533 -0
  57. package/lib/core/SiteWalker.js +218 -0
  58. package/lib/core/Syncer.js +163 -11
  59. package/lib/diagnostic-log.js +41 -0
  60. package/lib/environment-report.js +100 -0
  61. package/lib/global-config.js +4 -1
  62. package/lib/oauth-client.js +164 -0
  63. package/lib/origin.js +143 -0
  64. package/lib/service-api-client.js +95 -0
  65. package/lib/wallet-environment.js +49 -0
  66. package/lib/wallet-file.js +16 -2
  67. package/lib/wizard/setup.js +10 -9
  68. package/lib/workspace-config.js +380 -235
  69. package/lib/workspace-identity.js +92 -0
  70. package/lib/workspace-utils.js +41 -61
  71. package/package.json +19 -6
  72. package/templates/agent-claude.md +157 -9
  73. package/templates/agent-cline.md +68 -8
  74. package/templates/agent-copilot.md +68 -8
  75. package/templates/agent-cursor.md +68 -8
  76. package/templates/alignment-schema.json +20 -0
  77. package/templates/cloud-brain-hook.sh +20 -0
  78. package/templates/drive/README.md +1 -1
  79. package/templates/drive_templates/README.md +2 -2
  80. package/templates/scaffolds/README.md +6 -10
  81. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +30 -0
  82. package/templates/scaffolds/microservice/app.js +51 -10
  83. package/templates/scaffolds/microservice/app.yaml +2 -2
  84. package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
  85. package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
  86. package/templates/scaffolds/microservice/manifest.json +0 -1
  87. package/templates/scaffolds/microservice/package.json +3 -3
  88. package/templates/scaffolds/microservice/scripts/register.js +56 -41
  89. package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
  90. package/templates/scaffolds/microservice/services/utils.js +20 -238
  91. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
  92. package/lib/commands/app-wizard.js +0 -627
  93. package/lib/commands/folder.js +0 -291
  94. package/lib/core/PathContext.js +0 -335
  95. package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
  96. package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
  97. package/templates/scaffolds/site/README.md +0 -51
  98. package/templates/scaffolds/site/app.js +0 -22
  99. package/templates/scaffolds/site/index.html +0 -42
  100. package/templates/scaffolds/site/styles.css +0 -123
@@ -9,11 +9,13 @@ import chalk from 'chalk';
9
9
  import ora from 'ora';
10
10
  import { DeSciXApiClient } from '../api-client.js';
11
11
  import { WalletFileManager } from '../wallet-file.js';
12
+ import { walletEnvironmentStamp } from '../wallet-environment.js';
12
13
  import { WorkspaceConfig } from '../workspace-config.js';
13
14
  import { requireAuth } from '../auth-guard.js';
14
15
  import { exec } from 'child_process';
15
16
  import { promisify } from 'util';
16
17
  import * as fs from 'fs/promises';
18
+ import { diag } from '../diagnostic-log.js';
17
19
 
18
20
  const execAsync = promisify(exec);
19
21
  const POLL_INTERVAL_MS = 3000; // 3 seconds
@@ -43,14 +45,47 @@ async function openBrowser(url) {
43
45
  }
44
46
 
45
47
  /**
46
- * Request device login via HTTP
48
+ * Request device login via HTTP.
49
+ * WS-HEADLESS-MVP-A1: optionally carries the device→OAuth bridge context so the server
50
+ * issues an OAuth authorization code alongside the session on completion (design §2.1).
47
51
  * @param {DeSciXApiClient} apiClient - The API client
52
+ * @param {Object|null} oauthCtx - { client_id, code_challenge, redirect_uri, scope }
48
53
  */
49
- async function requestDeviceLogin(apiClient) {
50
- const response = await apiClient.invoke('device_request_login', {}, { allowGuest: true });
54
+ async function requestDeviceLogin(apiClient, oauthCtx = null) {
55
+ const params = oauthCtx ? { oauth: oauthCtx } : {};
56
+ const response = await apiClient.invoke('device_request_login', params, { allowGuest: true });
51
57
  return response.message;
52
58
  }
53
59
 
60
+ /**
61
+ * WS-HEADLESS-MVP-A1 — prepare the OAuth leg of `descix login`:
62
+ * PKCE pair + a registered CLI public client (DCR, reusing a cached client_id when the
63
+ * wallet file already has one). Returns null (with a loud warning) when the server does
64
+ * not expose the OAuth AS — the wallet-sig login proceeds untouched (design §4.1:
65
+ * "Behind a flag; wallet-sig path untouched").
66
+ */
67
+ async function prepareOAuthLeg(apiClient, existingWallet, scope) {
68
+ const oauthClient = await import('../oauth-client.js');
69
+ const { verifier, challenge } = oauthClient.generatePkcePair();
70
+
71
+ let clientId = existingWallet?.oauth?.client_id || null;
72
+ if (!clientId) {
73
+ clientId = await oauthClient.registerOAuthClient(apiClient.baseUrl, {});
74
+ }
75
+
76
+ return {
77
+ oauthClient,
78
+ verifier,
79
+ clientId,
80
+ ctx: {
81
+ client_id: clientId,
82
+ code_challenge: challenge,
83
+ redirect_uri: oauthClient.OOB_REDIRECT_URI,
84
+ scope: scope || oauthClient.DEFAULT_OAUTH_SCOPE,
85
+ },
86
+ };
87
+ }
88
+
54
89
  /**
55
90
  * Poll for device login completion via HTTP
56
91
  */
@@ -69,11 +104,16 @@ async function pollDeviceLogin(apiClient, deviceCode, onStatus = null) {
69
104
  if (status === 'complete') {
70
105
  return {
71
106
  user_id: response.message.user_id,
107
+ email: response.message.email || null,
72
108
  session_token: response.message.session_token,
73
109
  wallet_address: response.message.wallet_address,
74
110
  signature: response.message.signature,
75
- community_id: response.message.community_id || 'descix',
76
- token_symbol: response.message.token_symbol || 'DAITA'
111
+ community_id: response.message.community_id,
112
+ token_symbol: response.message.token_symbol || 'DAITA',
113
+ // WS-A1 device→OAuth bridge: single-use authorization code (present exactly once
114
+ // when the request carried an oauth ctx) — redeemed at /oauth/token immediately.
115
+ oauth_code: response.message.oauth_code || null,
116
+ oauth_scope: response.message.oauth_scope || null
77
117
  };
78
118
  }
79
119
 
@@ -102,13 +142,13 @@ async function pollDeviceLogin(apiClient, deviceCode, onStatus = null) {
102
142
  export async function loginDevice(options = {}) {
103
143
  const apiClient = new DeSciXApiClient({ baseUrl: options.url });
104
144
 
105
- console.log(chalk.cyan('\n🔐 DeSciX Device Login\n'));
145
+ diag(chalk.cyan('\n🔐 DeSciX Device Login\n'));
106
146
 
107
147
  const spinner = ora('Requesting device login...').start();
108
148
 
109
149
  // Ensure we have the base URL resolved
110
150
  await apiClient.ensureBaseUrl();
111
- console.log(chalk.gray(` API: ${apiClient.baseUrl}\n`));
151
+ diag(chalk.gray(` API: ${apiClient.baseUrl}\n`));
112
152
 
113
153
  try {
114
154
  // Detect workspace root
@@ -124,19 +164,45 @@ export async function loginDevice(options = {}) {
124
164
  workspaceRoot = foundRoot || startDir;
125
165
  }
126
166
 
127
- // Request device login
128
- const request = await requestDeviceLogin(apiClient);
167
+ // WS-HEADLESS-MVP-A1: OAuth leg (default ON, `--no-oauth` disables). Additive: any
168
+ // failure here warns loudly and the wallet-sig device login proceeds untouched.
169
+ let oauthLeg = null;
170
+ if (options.oauth !== false) {
171
+ try {
172
+ const existingWallet = await WalletFileManager.loadWalletFile(
173
+ WalletFileManager.getProjectWalletPath(workspaceRoot));
174
+ oauthLeg = await prepareOAuthLeg(apiClient, existingWallet, options.scope);
175
+ } catch (e) {
176
+ diag(chalk.yellow(`\n⚠️ OAuth token leg unavailable (${e.message}).`));
177
+ diag(chalk.yellow(' Proceeding with wallet-signature login only.\n'));
178
+ }
179
+ }
180
+
181
+ // Request device login (retry ONCE on a stale cached OAuth client_id)
182
+ let request;
183
+ try {
184
+ request = await requestDeviceLogin(apiClient, oauthLeg?.ctx || null);
185
+ } catch (e) {
186
+ if (oauthLeg && /invalid_client/.test(e.message || '')) {
187
+ // Cached client_id no longer registered server-side — re-register and retry.
188
+ oauthLeg.clientId = await oauthLeg.oauthClient.registerOAuthClient(apiClient.baseUrl, {});
189
+ oauthLeg.ctx.client_id = oauthLeg.clientId;
190
+ request = await requestDeviceLogin(apiClient, oauthLeg.ctx);
191
+ } else {
192
+ throw e;
193
+ }
194
+ }
129
195
 
130
196
  spinner.succeed('Device login request created');
131
197
 
132
- console.log(chalk.yellow('\n📱 Browser will open automatically...\n'));
133
- console.log(chalk.white('In the browser, you will need to:'));
134
- console.log(chalk.gray(' 1. Enter your email address'));
135
- console.log(chalk.gray(' 2. Verify your email with the code sent'));
136
- console.log(chalk.gray(' 3. Accept the Terms of Service'));
137
- console.log(chalk.gray(' 4. Connect your crypto wallet'));
138
- console.log(chalk.gray(' 5. Sign the message to prove wallet ownership'));
139
- console.log('');
198
+ diag(chalk.yellow('\n📱 Browser will open automatically...\n'));
199
+ diag(chalk.white('In the browser, you will need to:'));
200
+ diag(chalk.gray(' 1. Enter your email address'));
201
+ diag(chalk.gray(' 2. Verify your email with the code sent'));
202
+ diag(chalk.gray(' 3. Accept the Terms of Service'));
203
+ diag(chalk.gray(' 4. Connect your crypto wallet'));
204
+ diag(chalk.gray(' 5. Sign the message to prove wallet ownership'));
205
+ diag('');
140
206
 
141
207
  spinner.start('Opening browser...');
142
208
 
@@ -146,10 +212,10 @@ export async function loginDevice(options = {}) {
146
212
  const opened = await openBrowser(verificationUrl);
147
213
  if (opened) {
148
214
  spinner.succeed('Browser opened');
149
- console.log(chalk.yellow(`\nURL: ${verificationUrl}\n`));
215
+ diag(chalk.yellow(`\nURL: ${verificationUrl}\n`));
150
216
  } else {
151
217
  spinner.warn('Could not open browser automatically');
152
- console.log(chalk.yellow(`\nPlease open: ${verificationUrl}\n`));
218
+ diag(chalk.yellow(`\nPlease open: ${verificationUrl}\n`));
153
219
  }
154
220
 
155
221
  // Poll for completion
@@ -168,15 +234,45 @@ export async function loginDevice(options = {}) {
168
234
  userId: credentials.user_id,
169
235
  email: credentials.email,
170
236
  sessionToken: credentials.session_token,
171
- expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString()
237
+ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
238
+ // Record WHERE this credential was obtained. Write-only: nothing refuses on it yet.
239
+ ...walletEnvironmentStamp(apiClient.baseUrl)
172
240
  };
173
-
241
+
242
+ // WS-HEADLESS-MVP-A1: redeem the device-bridge authorization code for the long-lived
243
+ // OAuth pair (design §3) and cache it gcloud-ADC-style in wallet.json. The AS's auth
244
+ // codes are single-use with a ≤60s TTL — redeem BEFORE anything else.
245
+ if (oauthLeg && credentials.oauth_code) {
246
+ try {
247
+ const tokens = await oauthLeg.oauthClient.redeemAuthorizationCode(apiClient.baseUrl, {
248
+ code: credentials.oauth_code,
249
+ clientId: oauthLeg.clientId,
250
+ codeVerifier: oauthLeg.verifier,
251
+ });
252
+ walletData.oauth = oauthLeg.oauthClient.buildOAuthCredentialBlock({
253
+ clientId: oauthLeg.clientId,
254
+ tokens,
255
+ tokenEndpointBase: apiClient.baseUrl,
256
+ });
257
+ } catch (e) {
258
+ diag(chalk.yellow(`\n⚠️ OAuth code redemption failed: ${e.message}`));
259
+ diag(chalk.yellow(' Wallet-signature login is still valid; re-run "descix login" for OAuth tokens.\n'));
260
+ }
261
+ } else if (oauthLeg && !credentials.oauth_code) {
262
+ diag(chalk.yellow('\n⚠️ Server did not issue an OAuth code (device→OAuth bridge not available).'));
263
+ diag(chalk.yellow(' Wallet-signature login is still valid.\n'));
264
+ }
265
+
174
266
  const walletPath = WalletFileManager.getProjectWalletPath(workspaceRoot);
175
267
  await WalletFileManager.saveWalletFile(walletPath, walletData);
176
-
268
+
177
269
  spinner.succeed(chalk.green('Login successful!'));
178
- console.log(chalk.cyan(`\n✅ Credentials saved to:`));
179
- console.log(chalk.gray(` ${walletPath}\n`));
270
+ if (walletData.oauth) {
271
+ diag(chalk.green(' OAuth tokens cached (30-day silent refresh — gcloud-style).'));
272
+ diag(chalk.gray(` Scope: ${walletData.oauth.scope}`));
273
+ }
274
+ diag(chalk.cyan(`\n✅ Credentials saved to:`));
275
+ diag(chalk.gray(` ${walletPath}\n`));
180
276
 
181
277
  // Sync auto-purchase apps
182
278
  spinner.start('Syncing app entitlements...');
@@ -198,28 +294,28 @@ export async function loginDevice(options = {}) {
198
294
  }
199
295
  } catch (error) {
200
296
  spinner.warn(chalk.yellow('Could not sync app entitlements'));
201
- console.log(chalk.gray(` ${error.message}\n`));
297
+ diag(chalk.gray(` ${error.message}\n`));
202
298
  }
203
299
 
204
- console.log(chalk.white('You can now use all DeSciX CLI commands!\n'));
300
+ diag(chalk.white('You can now use all DeSciX CLI commands!\n'));
205
301
 
206
302
  } catch (error) {
207
303
  spinner.fail(chalk.red('Login failed'));
208
304
  console.error(chalk.red(error.message));
209
305
 
210
306
  if (error.message.includes('expired')) {
211
- console.log(chalk.yellow('💡 Tip: Device codes expire after 15 minutes.'));
212
- console.log(chalk.yellow(' Run the login command again to get a new code.\n'));
307
+ diag(chalk.yellow('💡 Tip: Device codes expire after 15 minutes.'));
308
+ diag(chalk.yellow(' Run the login command again to get a new code.\n'));
213
309
  } else {
214
- console.log(chalk.yellow('💡 Tip: Make sure you:'));
215
- console.log(chalk.yellow(' - Have an internet connection'));
216
- console.log(chalk.yellow(' - Can access the DeSciX platform'));
217
- console.log(chalk.yellow(' - Complete all steps in the browser'));
218
- console.log(chalk.yellow(` - API URL is correct: ${apiClient.baseUrl || 'unknown'}`));
310
+ diag(chalk.yellow('💡 Tip: Make sure you:'));
311
+ diag(chalk.yellow(' - Have an internet connection'));
312
+ diag(chalk.yellow(' - Can access the DeSciX platform'));
313
+ diag(chalk.yellow(' - Complete all steps in the browser'));
314
+ diag(chalk.yellow(` - API URL is correct: ${apiClient.baseUrl || 'unknown'}`));
219
315
  if (apiClient.baseUrl?.includes('localhost')) {
220
- console.log(chalk.yellow(' - Local backend server is running'));
316
+ diag(chalk.yellow(' - Local backend server is running'));
221
317
  }
222
- console.log('');
318
+ diag('');
223
319
  }
224
320
  throw error;
225
321
  }
@@ -229,11 +325,11 @@ export async function loginDevice(options = {}) {
229
325
  * Direct wallet login command (not yet implemented)
230
326
  */
231
327
  export async function loginWallet() {
232
- console.log(chalk.cyan('\n🔐 DeSciX Wallet Login\n'));
233
- console.log(chalk.yellow('\n⚠️ Direct wallet login requires a Web3 provider.\n'));
234
- console.log(chalk.white('For CLI/MCP, device login is recommended:\n'));
235
- console.log(chalk.cyan(' descix login\n'));
236
- console.log(chalk.gray('This opens your browser where you can connect your wallet.\n'));
328
+ diag(chalk.cyan('\n🔐 DeSciX Wallet Login\n'));
329
+ diag(chalk.yellow('\n⚠️ Direct wallet login requires a Web3 provider.\n'));
330
+ diag(chalk.white('For CLI/MCP, device login is recommended:\n'));
331
+ diag(chalk.cyan(' descix login\n'));
332
+ diag(chalk.gray('This opens your browser where you can connect your wallet.\n'));
237
333
  throw new Error('Direct wallet login not yet implemented for CLI. Use device login instead.');
238
334
  }
239
335
 
@@ -284,10 +380,10 @@ export async function reconnect() {
284
380
  await WalletFileManager.saveWalletFile(walletPath, updatedWalletData);
285
381
 
286
382
  spinner.succeed(chalk.green('Reconnected successfully!'));
287
- console.log(chalk.cyan(`\n✅ Status: ${result.auth_status || 'CONNECTED'}`));
288
- console.log(chalk.gray(` User: ${sessionInfo?.email || sessionInfo?.id || walletInfo.userId}`));
289
- console.log(chalk.gray(` Wallet: ${walletInfo.walletAddress?.substring(0, 10)}...`));
290
- console.log(chalk.gray(` Session refreshed\n`));
383
+ diag(chalk.cyan(`\n✅ Status: ${result.auth_status || 'CONNECTED'}`));
384
+ diag(chalk.gray(` User: ${sessionInfo?.email || sessionInfo?.id || walletInfo.userId}`));
385
+ diag(chalk.gray(` Wallet: ${walletInfo.walletAddress?.substring(0, 10)}...`));
386
+ diag(chalk.gray(` Session refreshed\n`));
291
387
 
292
388
  } catch (error) {
293
389
  spinner.fail(chalk.red('Reconnection failed'));
@@ -307,7 +403,7 @@ export async function whoami() {
307
403
  try {
308
404
  await requireAuth(apiClient);
309
405
 
310
- const response = await apiClient.invoke('check_staked_status', {}, { allowGuest: false });
406
+ const response = await apiClient.invoke('validate_session', {}, { allowGuest: false });
311
407
  const userInfo = response.message;
312
408
 
313
409
  const walletPath = await WalletFileManager.findWalletFile(process.cwd());
@@ -316,60 +412,60 @@ export async function whoami() {
316
412
 
317
413
  spinner.succeed(chalk.green('Authentication status'));
318
414
 
319
- console.log(chalk.cyan('\n📋 Current Session:\n'));
320
- console.log(chalk.white(` User ID: ${userInfo?.id || userInfo?.user_id || walletInfo?.userId || 'Unknown'}`));
321
- console.log(chalk.white(` Email: ${userInfo?.email || walletInfo?.email || 'N/A'}`));
415
+ diag(chalk.cyan('\n📋 Current Session:\n'));
416
+ diag(chalk.white(` User ID: ${userInfo?.id || userInfo?.user_id || walletInfo?.userId || 'Unknown'}`));
417
+ diag(chalk.white(` Email: ${userInfo?.email || walletInfo?.email || 'N/A'}`));
322
418
  if (walletInfo) {
323
- console.log(chalk.white(` Wallet: ${walletInfo.walletAddress?.substring(0, 10)}...${walletInfo.walletAddress?.slice(-6) || ''}`));
419
+ diag(chalk.white(` Wallet: ${walletInfo.walletAddress?.substring(0, 10)}...${walletInfo.walletAddress?.slice(-6) || ''}`));
324
420
  if (walletInfo.communityId) {
325
- console.log(chalk.white(` Community: ${walletInfo.communityId}`));
421
+ diag(chalk.white(` Community: ${walletInfo.communityId}`));
326
422
  }
327
423
  if (walletInfo.tokenSymbol) {
328
- console.log(chalk.white(` Token: ${walletInfo.tokenSymbol}`));
424
+ diag(chalk.white(` Token: ${walletInfo.tokenSymbol}`));
329
425
  }
330
- console.log(chalk.white(` Session: ${isValid ? chalk.green('Valid') : chalk.red('Expired')}`));
426
+ diag(chalk.white(` Session: ${isValid ? chalk.green('Valid') : chalk.red('Expired')}`));
331
427
 
332
428
  if (walletInfo.expiresAt) {
333
429
  const expiresDate = new Date(walletInfo.expiresAt);
334
430
  const now = new Date();
335
431
  if (expiresDate > now) {
336
432
  const hoursLeft = Math.round((expiresDate - now) / (1000 * 60 * 60));
337
- console.log(chalk.white(` Expires: ${hoursLeft}h remaining`));
433
+ diag(chalk.white(` Expires: ${hoursLeft}h remaining`));
338
434
  } else {
339
- console.log(chalk.white(` Expires: ${chalk.red('Expired')}`));
435
+ diag(chalk.white(` Expires: ${chalk.red('Expired')}`));
340
436
  }
341
437
  }
342
438
  }
343
439
 
344
440
  if (userInfo) {
345
- console.log(chalk.cyan('\n📊 Account Details:\n'));
346
- console.log(chalk.white(` Balance: ${userInfo.custodial_usd_balance || userInfo.balance || 0} USDCX`));
347
- console.log(chalk.white(` Staked: ${userInfo.is_staked ? chalk.green('Yes') : chalk.yellow('No')}`));
348
-
441
+ // Account Details: feature access is entitlement-based (community membership / subscriptions),
442
+ // NOT a custodial balance or a STAKED state — both removed per CEO-D-2026-06-14-DROP-CUSTODIAL-ELIMINATE-STAKED.
443
+ diag(chalk.cyan('\n📊 Account Details:\n'));
444
+
349
445
  try {
350
446
  const purchasesResponse = await apiClient.invoke('fetch_my_purchases', { product_type: 'APP' }, { allowGuest: false });
351
447
  const purchases = purchasesResponse.message || {};
352
448
  const communities = purchases.communities || [];
353
449
  const apps = purchases.apps || purchases.products || [];
354
450
 
355
- console.log(chalk.white(` Communities: ${communities.length}`));
356
- console.log(chalk.white(` Apps: ${apps.length}`));
451
+ diag(chalk.white(` Communities: ${communities.length}`));
452
+ diag(chalk.white(` Apps: ${apps.length}`));
357
453
  if (apps.length > 0) {
358
454
  apps.slice(0, 5).forEach(a => {
359
455
  const id = a.app_id || a.appId || a.id || a.product_id;
360
- if (id) console.log(chalk.gray(` - ${id}`));
456
+ if (id) diag(chalk.gray(` - ${id}`));
361
457
  });
362
- if (apps.length > 5) console.log(chalk.gray(` ... and ${apps.length - 5} more`));
458
+ if (apps.length > 5) diag(chalk.gray(` ... and ${apps.length - 5} more`));
363
459
  }
364
460
  } catch (err) {
365
461
  // Ignore purchase fetch errors
366
462
  }
367
463
  }
368
464
 
369
- console.log('');
465
+ diag('');
370
466
 
371
467
  if (walletInfo && !isValid) {
372
- console.log(chalk.yellow(' ⚠️ Session expired. Run "descix reconnect" to refresh.\n'));
468
+ diag(chalk.yellow(' ⚠️ Session expired. Run "descix reconnect" to refresh.\n'));
373
469
  }
374
470
 
375
471
  } catch (error) {
@@ -380,28 +476,114 @@ export async function whoami() {
380
476
  }
381
477
 
382
478
  /**
383
- * Logout - clear credentials
479
+ * Logout - revoke server-side OAuth credentials (RFC 7009), then clear the local cache.
480
+ * WS-HEADLESS-MVP-A1 (design §4.6): revocation kills the whole refresh-token chain so a
481
+ * copied wallet.json cannot keep refreshing after logout. Revocation failure is reported
482
+ * but never blocks the local credential wipe.
384
483
  */
385
484
  export async function logout() {
386
485
  const spinner = ora('Logging out...').start();
387
-
486
+
388
487
  try {
389
488
  const walletPath = await WalletFileManager.findWalletFile(process.cwd());
390
-
489
+
391
490
  if (!walletPath) {
392
491
  spinner.info(chalk.yellow('Not logged in'));
393
- console.log(chalk.gray('\n No credentials to remove.\n'));
492
+ diag(chalk.gray('\n No credentials to remove.\n'));
394
493
  return;
395
494
  }
396
-
495
+
496
+ const walletInfo = await WalletFileManager.loadWalletFile(walletPath);
497
+ if (walletInfo?.oauth?.refresh_token) {
498
+ spinner.text = 'Revoking OAuth credentials...';
499
+ try {
500
+ const apiClient = new DeSciXApiClient();
501
+ await apiClient.ensureBaseUrl();
502
+ const { revokeOAuthToken } = await import('../oauth-client.js');
503
+ await revokeOAuthToken(apiClient.baseUrl, {
504
+ token: walletInfo.oauth.refresh_token,
505
+ clientId: walletInfo.oauth.client_id,
506
+ });
507
+ diag(chalk.gray('\n OAuth refresh-token chain revoked server-side.'));
508
+ } catch (e) {
509
+ diag(chalk.yellow(`\n ⚠️ OAuth revocation failed (${e.message}); removing local credentials anyway.`));
510
+ }
511
+ }
512
+
397
513
  await fs.unlink(walletPath);
398
-
514
+
399
515
  spinner.succeed(chalk.green('Logged out'));
400
- console.log(chalk.cyan('\n✅ Credentials removed\n'));
401
-
516
+ diag(chalk.cyan('\n✅ Credentials removed\n'));
517
+
402
518
  } catch (error) {
403
519
  spinner.fail(chalk.red('Logout failed'));
404
520
  console.error(chalk.red(error.message));
405
521
  throw error;
406
522
  }
407
523
  }
524
+
525
+ /**
526
+ * Admin bootstrap login — generates CLI credentials for platform admins
527
+ * without requiring the PWA device login / Powch flow.
528
+ * Requires email to be in the DESCIX_ADMIN_GROUP Google Group.
529
+ */
530
+ export async function adminLogin(options = {}) {
531
+ const email = options.email;
532
+ if (!email) {
533
+ console.error(chalk.red('--email is required'));
534
+ process.exit(1);
535
+ }
536
+
537
+ const spinner = ora('Authenticating as admin...').start();
538
+
539
+ try {
540
+ const apiClient = new DeSciXApiClient();
541
+ await apiClient.ensureBaseUrl();
542
+
543
+ // admin_bootstrap_login is a bootstrap command: it must be sent guest-like,
544
+ // never carrying a previously-stored access_token. Targeting a DIFFERENT env
545
+ // than the one that minted the stored token (e.g. CLI configured for dev but
546
+ // logging into prod via --env prod) would otherwise send a token prod's auth
547
+ // middleware rejects with HTTP 401 BEFORE admin_bootstrap_login runs. Force
548
+ // guest by skipping the session token, same as device_request_login.
549
+ const result = await apiClient.invokeRaw('admin_bootstrap_login', { email }, null, { skipSessionCheck: true });
550
+ const data = result.data || result;
551
+
552
+ if (data.status === 'ERROR') {
553
+ spinner.fail(data.message || 'Admin login failed');
554
+ process.exit(1);
555
+ }
556
+
557
+ const info = data.message;
558
+ if (!info || !info.session_token) {
559
+ spinner.fail('Unexpected response from server');
560
+ process.exit(1);
561
+ }
562
+
563
+ const walletData = {
564
+ walletAddress: info.wallet_address,
565
+ signature: info.signature,
566
+ sessionToken: info.session_token,
567
+ userId: info.user_id,
568
+ email: info.email,
569
+ communityId: info.community_id,
570
+ tokenSymbol: info.token_symbol || 'DAITA',
571
+ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
572
+ // Record WHERE this credential was obtained. Write-only: nothing refuses on it yet.
573
+ ...walletEnvironmentStamp(apiClient.baseUrl),
574
+ };
575
+
576
+ // Save wallet.json
577
+ const workspaceRoot = await WorkspaceConfig.findWorkspaceRoot(process.cwd()) || process.cwd();
578
+ const walletPath = WalletFileManager.getProjectWalletPath(workspaceRoot);
579
+ await WalletFileManager.saveWalletFile(walletPath, walletData);
580
+
581
+ spinner.succeed(chalk.green(`Logged in as admin: ${email}`));
582
+ diag(chalk.dim(` Wallet: ${walletData.walletAddress}`));
583
+ diag(chalk.dim(` Session expires: ${walletData.expiresAt}`));
584
+ diag(chalk.dim(` Credentials saved to: ${walletPath}`));
585
+ } catch (error) {
586
+ spinner.fail(error.message || 'Admin login failed');
587
+ process.exit(1);
588
+ }
589
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * BrieferExtractorError — canonical hard-fail surface for the `descix briefer` CLI.
3
+ *
4
+ * Per WS-DESCIX-BRIEFER-CLI scope doc §2.3 ("Hard-fail philosophy"), every data
5
+ * source has exactly one extraction method and exactly one failure mode:
6
+ * HARD-FAIL with an actionable error message.
7
+ *
8
+ * Per feedback_no-hardcoded-fallbacks: NO fallback values, NO best-effort prose,
9
+ * NO try-catch-and-default. If a source moved or changed shape, the CLI exits
10
+ * non-zero and the canonical briefer file remains untouched.
11
+ *
12
+ * Stable error codes (per scope doc §2.3) — grep-friendly for diagnostics:
13
+ * BRIEFER-SRC-NOT-FOUND Source file missing / line range no longer matches
14
+ * BRIEFER-GCLOUD-AUTH gcloud auth missing or insufficient scope
15
+ * BRIEFER-GCLOUD-FAIL gcloud probe failed (auth/empty/timeout) when --env=demo|prod required it
16
+ * BRIEFER-PARSE-FAIL Source file present but construct unparseable
17
+ * BRIEFER-DRIFT-DETECTED --check diff against canonical briefer non-empty
18
+ * BRIEFER-NEGATIVE-CLAIM Briefer claims X does not exist, but found at Y:Z
19
+ * BRIEFER-FIRESTORE Firestore read via apifront failed
20
+ * BRIEFER-NOT-IMPLEMENTED M1 stub — extractor scaffold-only, real impl in later milestone
21
+ *
22
+ * Per scope doc §2.3, every error message must include:
23
+ * 1. The exact source path + line range that was being read.
24
+ * 2. The construct shape that was expected.
25
+ * 3. What the operator should do to recover.
26
+ * 4. A stable error code from the list above.
27
+ */
28
+
29
+ export const BRIEFER_ERROR_CODES = Object.freeze({
30
+ SRC_NOT_FOUND: 'BRIEFER-SRC-NOT-FOUND',
31
+ GCLOUD_AUTH: 'BRIEFER-GCLOUD-AUTH',
32
+ PARSE_FAIL: 'BRIEFER-PARSE-FAIL',
33
+ DRIFT_DETECTED: 'BRIEFER-DRIFT-DETECTED',
34
+ NEGATIVE_CLAIM: 'BRIEFER-NEGATIVE-CLAIM',
35
+ FIRESTORE: 'BRIEFER-FIRESTORE',
36
+ GCLOUD_FAIL: 'BRIEFER-GCLOUD-FAIL',
37
+ NOT_IMPLEMENTED: 'BRIEFER-NOT-IMPLEMENTED',
38
+ WORKSPACE_NOT_FOUND: 'BRIEFER-WORKSPACE-NOT-FOUND'
39
+ });
40
+
41
+ export class BrieferExtractorError extends Error {
42
+ /**
43
+ * @param {object} args
44
+ * @param {string} args.code One of BRIEFER_ERROR_CODES (REQUIRED).
45
+ * @param {string} args.section Briefer section being extracted (e.g., '§3 routing').
46
+ * @param {string} args.source File path or 'gcloud' or 'firestore' (REQUIRED).
47
+ * @param {string} args.expected Construct shape that was expected (REQUIRED).
48
+ * @param {string} args.recovery Concrete next step for the operator (REQUIRED).
49
+ * @param {string} [args.detail] Optional extra context (e.g., parser msg, exit code).
50
+ */
51
+ constructor({ code, section, source, expected, recovery, detail } = {}) {
52
+ if (!code || !Object.values(BRIEFER_ERROR_CODES).includes(code)) {
53
+ // Hard-fail loud on misuse — caller is constructing a bad error.
54
+ // We do NOT default the code; per feedback_no-hardcoded-fallbacks the
55
+ // absence of a code is itself a misconfiguration we want to surface.
56
+ throw new Error(
57
+ `BrieferExtractorError requires a valid 'code'. Got: ${JSON.stringify(code)}. ` +
58
+ `Valid codes: ${Object.values(BRIEFER_ERROR_CODES).join(', ')}.`
59
+ );
60
+ }
61
+ if (!source) {
62
+ throw new Error(`BrieferExtractorError requires a 'source' (file path / 'gcloud' / 'firestore').`);
63
+ }
64
+ if (!expected) {
65
+ throw new Error(`BrieferExtractorError requires an 'expected' construct shape description.`);
66
+ }
67
+ if (!recovery) {
68
+ throw new Error(`BrieferExtractorError requires a 'recovery' instruction for the operator.`);
69
+ }
70
+
71
+ const lines = [
72
+ `[${code}] ${section || '(section unspecified)'}`,
73
+ ` source: ${source}`,
74
+ ` expected: ${expected}`,
75
+ ` recovery: ${recovery}`
76
+ ];
77
+ if (detail) lines.push(` detail: ${detail}`);
78
+
79
+ super(lines.join('\n'));
80
+ this.name = 'BrieferExtractorError';
81
+ this.code = code;
82
+ this.section = section || null;
83
+ this.source = source;
84
+ this.expected = expected;
85
+ this.recovery = recovery;
86
+ this.detail = detail || null;
87
+ }
88
+ }