@descix/cli 1.0.6 → 1.0.7

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.
@@ -21,9 +21,12 @@
21
21
  import chalk from 'chalk';
22
22
  import ora from 'ora';
23
23
  import inquirer from 'inquirer';
24
+ // "May I prompt?" has ONE OWNER. The wizard is interactive BY NATURE; it still asks the owner.
25
+ import { requireInteractive } from '../interactive.js';
24
26
  import { DeSciXApiClient } from '../api-client.js';
25
27
  import { ENV_ORIGINS } from '@descix/app-sdk/dev';
26
28
  import { requireAuth, isAuthenticated } from '../auth-guard.js';
29
+ import { WorkspaceConfig } from '../workspace-config.js';
27
30
  import * as authCommands from '../commands/auth.js';
28
31
  import * as fs from 'fs/promises';
29
32
  import * as path from 'path';
@@ -179,12 +182,96 @@ function getMcpServerConfig() {
179
182
  };
180
183
  }
181
184
 
185
+ /**
186
+ * Refuse to run when a workspace already exists here.
187
+ *
188
+ * quickstart CREATES a workspace. It has no migration or merge semantics, so running it over a
189
+ * present one can only destroy information. This is a REFUSAL and deliberately NOT a prompt:
190
+ * an agent caller hangs forever on a question, and a refusal is machine-readable where a
191
+ * question is not.
192
+ *
193
+ * WHERE THE WORKSPACE ROOT IS has ONE OWNER: WorkspaceConfig.findWorkspaceRoot, which walks UP
194
+ * from the start directory exactly as load() does. This guard CONSUMES it. It used to derive the
195
+ * answer a SECOND time by reading the TARGET PATH ONLY, and the two derivations disagreed in the
196
+ * case that matters: run from a SUBDIRECTORY of an existing workspace, the target-path check saw
197
+ * nothing, the wizard proceeded, and it wrote a NESTED workspace.json that SHADOWS the parent for
198
+ * every later load() — because load() walks up and stops at the FIRST one it finds. The second
199
+ * derivation is DELETED, not left beside the owner.
200
+ *
201
+ * A nested workspace is not an unsupported topology, it is the BUG: because resolution walks UP,
202
+ * a nested file is unreachable by design from below EXCEPT by shadowing its parent. There is no
203
+ * escape hatch here and no flag to request one.
204
+ *
205
+ * findWorkspaceRoot stats the file, so a PRESENT-BUT-UNREADABLE workspace is still FOUND and still
206
+ * refused — the corrupt-looks-absent case this guard exists to stop is preserved.
207
+ *
208
+ * @param {string} startDir - directory the wizard was invoked in
209
+ * @throws {Error} naming the root it found and what is there, when a workspace exists at or ABOVE
210
+ * startDir
211
+ */
212
+ async function refuseIfWorkspacePresent(startDir) {
213
+ const workspaceRoot = await WorkspaceConfig.findWorkspaceRoot(startDir);
214
+ if (!workspaceRoot) return; // ordinary first run — nothing up the tree to protect
215
+
216
+ const configPath = path.join(workspaceRoot, '.descix', 'workspace.json');
217
+ const nested = path.resolve(workspaceRoot) !== path.resolve(startDir);
218
+
219
+ let raw;
220
+ try {
221
+ raw = await fs.readFile(configPath, 'utf-8');
222
+ } catch (err) {
223
+ if (err.code === 'ENOENT') return; // stat'd a moment ago; it vanished in between
224
+ throw new Error(
225
+ `Refusing to run quickstart: ${configPath} exists but could not be read (${err.message}).\n` +
226
+ (nested ? `It is the workspace root for ${workspaceRoot}, which CONTAINS ${startDir}.\n` : '') +
227
+ 'Quickstart will not write over a file it cannot inspect.'
228
+ );
229
+ }
230
+
231
+ let found;
232
+ try {
233
+ const parsed = JSON.parse(raw);
234
+ const version = parsed && parsed.version ? parsed.version : 'no version field';
235
+ found = `version ${version}`;
236
+ } catch (err) {
237
+ found = `unparseable JSON (${err.message})`;
238
+ }
239
+
240
+ throw new Error(
241
+ (nested
242
+ ? `Refusing to create a workspace here — ${startDir} is already INSIDE the workspace ` +
243
+ `rooted at ${workspaceRoot}.\n` +
244
+ ` Existing workspace: ${configPath} (${found})\n` +
245
+ '\nCreating a second workspace.json in this subdirectory would SHADOW that one: workspace ' +
246
+ 'resolution walks UP and stops at the FIRST file it finds, so every later command run from ' +
247
+ 'here would silently use the new file instead of the real one.\n'
248
+ : `Refusing to overwrite ${configPath} — a workspace is already configured here (${found}).\n` +
249
+ '\nquickstart CREATES a workspace; it does not migrate or merge an existing one, and ' +
250
+ 'writing over this file would destroy whatever it holds.\n') +
251
+ '\nTo change the workspace you already have, use the verbs that own it' +
252
+ (nested ? ` (run them from ${workspaceRoot}):\n` : ':\n') +
253
+ ' descix config show # what is configured right now\n' +
254
+ ' descix config init --env <env> # retarget to a known environment (dev|demo|prod)\n' +
255
+ ' descix config set-env <name> --url <origin> # a custom API origin under your own env name\n' +
256
+ ' descix config set-gateway-port <n> # change the gateway port\n' +
257
+ ' descix app init # register another app in this workspace\n'
258
+ );
259
+ }
260
+
182
261
  // ============ Main Wizard ============
183
262
 
184
263
  /**
185
264
  * Run the interactive setup wizard
186
265
  */
187
266
  export async function runSetupWizard(options = {}) {
267
+ // ONE derivation of the workspace root, shared by the guard and the write it protects.
268
+ const workspaceRoot = process.cwd();
269
+
270
+ // BEFORE the banner and before every side effect: this wizard also writes
271
+ // .cursor/mcp.json and pulls SDK assets, so a guard placed further down would
272
+ // refuse only after those had already landed.
273
+ await refuseIfWorkspacePresent(workspaceRoot);
274
+
188
275
  console.log(chalk.cyan('\n┌─────────────────────────────────────────────────┐'));
189
276
  console.log(chalk.cyan('│ Welcome to DeSciX CLI/MCP Setup │'));
190
277
  console.log(chalk.cyan('├─────────────────────────────────────────────────┤'));
@@ -200,7 +287,6 @@ export async function runSetupWizard(options = {}) {
200
287
  console.log(chalk.cyan('│ configure your workspace folders. │'));
201
288
  console.log(chalk.cyan('└─────────────────────────────────────────────────┘\n'));
202
289
 
203
- const workspaceRoot = process.cwd();
204
290
  const spinner = ora('Initializing...').start();
205
291
 
206
292
  try {
@@ -211,6 +297,7 @@ export async function runSetupWizard(options = {}) {
211
297
  let apiUrl = options.url || process.env.DESCIX_API_URL;
212
298
 
213
299
  if (!apiUrl) {
300
+ requireInteractive({ what: 'descix mcp quickstart', question: 'which environment?', nonInteractiveForm: ['descix mcp quickstart is an interactive wizard and has no non-interactive form. Run it in a terminal.'] });
214
301
  const { environment } = await inquirer.prompt([
215
302
  {
216
303
  type: 'list',
@@ -230,6 +317,7 @@ export async function runSetupWizard(options = {}) {
230
317
  } else if (environment === 'local') {
231
318
  apiUrl = 'https://localhost:4000';
232
319
  } else {
320
+ requireInteractive({ what: 'descix mcp quickstart', question: 'custom API URL?', nonInteractiveForm: ['descix mcp quickstart is an interactive wizard and has no non-interactive form. Run it in a terminal.'] });
233
321
  const { customUrl } = await inquirer.prompt([
234
322
  {
235
323
  type: 'input',
@@ -265,6 +353,7 @@ export async function runSetupWizard(options = {}) {
265
353
  if (!isAuth) {
266
354
  console.log(chalk.yellow('⚠️ Authentication is required to use DeSciX CLI/MCP.\n'));
267
355
 
356
+ requireInteractive({ what: 'descix mcp quickstart', question: 'which authentication method?', nonInteractiveForm: ['descix mcp quickstart is an interactive wizard and has no non-interactive form. Run it in a terminal.'] });
268
357
  const { authMethod } = await inquirer.prompt([
269
358
  {
270
359
  type: 'list',
@@ -357,6 +446,7 @@ export async function runSetupWizard(options = {}) {
357
446
  // Step 4: MCP Configuration
358
447
  console.log(chalk.cyan('\n📋 Step 4: MCP Configuration\n'));
359
448
 
449
+ requireInteractive({ what: 'descix mcp quickstart', question: 'configure MCP now?', nonInteractiveForm: ['descix mcp quickstart is an interactive wizard and has no non-interactive form. Run it in a terminal.'] });
360
450
  const { setupMCP } = await inquirer.prompt([
361
451
  {
362
452
  type: 'confirm',
@@ -437,27 +527,19 @@ export async function runSetupWizard(options = {}) {
437
527
  // Step 5: Create workspace config
438
528
  spinner.start('Creating workspace configuration...');
439
529
 
440
- const config = {
441
- version: '2.0',
442
- primaryCommunity,
443
- directoryMappings: {}, // Empty - AI agent will configure after setup
444
- additionalContexts: [],
445
- defaultContext: {
446
- communityId: primaryCommunity,
447
- appId: 'daita',
448
- kbId: 'General'
449
- },
450
- apiUrl: apiUrl,
451
- environment: apiUrl.includes('localhost') ? 'development' : 'production',
452
- _setupStatus: 'mcp_ready',
453
- _needsAgentSetup: true, // Flag for AI agent to help with workspace config
454
- _sdkVersion: SDK_VERSION
455
- };
456
-
457
- const descixDir = path.join(workspaceRoot, '.descix');
458
- await fs.mkdir(descixDir, { recursive: true });
459
- const configPath = path.join(descixDir, 'workspace.json');
460
- await fs.writeFile(configPath, JSON.stringify(config, null, 2));
530
+ // The SHAPE of this file is owned by WorkspaceConfig and it is written ONLY via save().
531
+ // This used to build a config literal and call fs.writeFile directly — no existence check,
532
+ // no parse check, no save() — which both clobbered present workspaces and stamped a
533
+ // version '2.0' shape on a v2.1 platform. The version is the owner's concern now.
534
+ //
535
+ // The env NAME comes from the owner's own ENV_MAP rather than being re-derived from the URL
536
+ // here; an origin the owner does not know is recorded as DEV carrying its explicit URL,
537
+ // because inventing a label at this call site would be a second derivation of one fact.
538
+ const envName = (Object.entries(WorkspaceConfig.ENV_MAP)
539
+ .find(([, entry]) => entry.url === apiUrl) || ['dev'])[0];
540
+
541
+ const workspace = new WorkspaceConfig({ type: 'workspace' }, workspaceRoot);
542
+ const { configPath } = await workspace.setEnvironment(envName, apiUrl);
461
543
 
462
544
  spinner.succeed('Workspace configured');
463
545
  console.log(chalk.gray(` Created: .descix/workspace.json\n`));