@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.
- package/LICENSE.md +671 -0
- package/README.md +43 -4
- package/agent-assets/guides/README.md +2 -2
- package/agent-assets/guides/dynamic-site-pattern.md +29 -14
- package/agent-assets/guides/knowledge-only-pattern.md +5 -5
- package/agent-assets/guides/microservice-pattern.md +48 -4
- package/agent-assets/guides/static-site-pattern.md +8 -0
- package/agent-assets/instructions/app-asset-validation.md +5 -5
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/instructions/standard-folder-structure.md +20 -11
- package/agent-assets/reference/app-creation.md +42 -20
- package/agent-assets/reference/architecture.md +24 -22
- package/agent-assets/reference/local-dev.md +230 -67
- package/agent-assets/reference/mcp-integration.md +9 -9
- package/agent-assets/reference/platform-api-consumer.md +244 -0
- package/agent-assets/reference/workspace-config.md +7 -7
- package/agent-assets/rules/descix_mcp.template.mdc +8 -8
- package/bin/descix.js +2559 -1435
- package/bin/kb-deny-lint.mjs +91 -0
- package/bin/mcp-server.js +175 -91
- package/lib/agent-files.js +37 -25
- package/lib/api-client.js +223 -56
- package/lib/auth-guard.js +13 -3
- package/lib/chat-session.js +187 -0
- package/lib/cli-version.js +59 -0
- package/lib/commands/airdrop.js +251 -0
- package/lib/commands/auth.js +254 -72
- package/lib/commands/briefer/errors.js +88 -0
- package/lib/commands/briefer/index.js +319 -0
- package/lib/commands/briefer/sources/canonical-sources.js +129 -0
- package/lib/commands/briefer/sources/entitlements.js +209 -0
- package/lib/commands/briefer/sources/environments.js +205 -0
- package/lib/commands/briefer/sources/identifiers.js +214 -0
- package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
- package/lib/commands/briefer/sources/routing.js +275 -0
- package/lib/commands/briefer/sources/what-is-not.js +223 -0
- package/lib/commands/briefer/stitcher.js +134 -0
- package/lib/commands/briefer/util/source-reader.js +502 -0
- package/lib/commands/config.js +210 -90
- package/lib/commands/corpus.js +978 -0
- package/lib/commands/credits.js +122 -0
- package/lib/commands/health.js +627 -0
- package/lib/commands/kb-list-render.js +32 -0
- package/lib/commands/kb.js +190 -413
- package/lib/commands/mcp.js +5 -5
- package/lib/commands/microservice-restart.js +359 -0
- package/lib/commands/model-config.js +313 -0
- package/lib/commands/retired-kb-sync.js +134 -0
- package/lib/commands/serve.js +26 -1
- package/lib/commands/status.js +23 -51
- package/lib/commands/update.js +36 -107
- package/lib/core/Chunker.js +491 -17
- package/lib/core/CorpusDenyLint.js +326 -0
- package/lib/core/CorpusWalker.js +357 -0
- package/lib/core/Hydrator.js +75 -29
- package/lib/core/ManifestLoader.js +533 -0
- package/lib/core/SiteWalker.js +218 -0
- package/lib/core/Syncer.js +163 -11
- package/lib/diagnostic-log.js +41 -0
- package/lib/environment-report.js +100 -0
- package/lib/global-config.js +4 -1
- package/lib/oauth-client.js +164 -0
- package/lib/origin.js +143 -0
- package/lib/service-api-client.js +95 -0
- package/lib/wallet-environment.js +49 -0
- package/lib/wallet-file.js +16 -2
- package/lib/wizard/setup.js +10 -9
- package/lib/workspace-config.js +380 -235
- package/lib/workspace-identity.js +92 -0
- package/lib/workspace-utils.js +41 -61
- package/package.json +19 -6
- package/templates/agent-claude.md +157 -9
- package/templates/agent-cline.md +68 -8
- package/templates/agent-copilot.md +68 -8
- package/templates/agent-cursor.md +68 -8
- package/templates/alignment-schema.json +20 -0
- package/templates/cloud-brain-hook.sh +20 -0
- package/templates/drive/README.md +1 -1
- package/templates/drive_templates/README.md +2 -2
- package/templates/scaffolds/README.md +6 -10
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +30 -0
- package/templates/scaffolds/microservice/app.js +51 -10
- package/templates/scaffolds/microservice/app.yaml +2 -2
- package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
- package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
- package/templates/scaffolds/microservice/manifest.json +0 -1
- package/templates/scaffolds/microservice/package.json +3 -3
- package/templates/scaffolds/microservice/scripts/register.js +56 -41
- package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
- package/templates/scaffolds/microservice/services/utils.js +20 -238
- package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
- package/lib/commands/app-wizard.js +0 -627
- package/lib/commands/folder.js +0 -291
- package/lib/core/PathContext.js +0 -335
- package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
- package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
- package/templates/scaffolds/site/README.md +0 -51
- package/templates/scaffolds/site/app.js +0 -22
- package/templates/scaffolds/site/index.html +0 -42
- package/templates/scaffolds/site/styles.css +0 -123
package/bin/descix.js
CHANGED
|
@@ -12,27 +12,122 @@ import chalk from 'chalk';
|
|
|
12
12
|
import { DeSciXApiClient } from '../lib/api-client.js';
|
|
13
13
|
import { requireAuth } from '../lib/auth-guard.js';
|
|
14
14
|
import { WorkspaceConfig } from '../lib/workspace-config.js';
|
|
15
|
+
import { CLI_VERSION } from '../lib/cli-version.js';
|
|
16
|
+
// Chat session pointer + the ONE rule for when a dead pointer may be self-healed.
|
|
17
|
+
import {
|
|
18
|
+
getSessionInteractionId,
|
|
19
|
+
findSessionsForApp,
|
|
20
|
+
saveSessionAuthoritative,
|
|
21
|
+
clearAppSessions,
|
|
22
|
+
isStaleThreadError,
|
|
23
|
+
} from '../lib/chat-session.js';
|
|
15
24
|
import { GlobalConfig } from '../lib/global-config.js';
|
|
16
25
|
import * as authCommands from '../lib/commands/auth.js';
|
|
17
26
|
import * as configCommands from '../lib/commands/config.js';
|
|
18
|
-
import { runAppWizard } from '../lib/commands/app-wizard.js';
|
|
19
27
|
import * as buyCommands from '../lib/commands/buy.js';
|
|
28
|
+
import * as creditsCommands from '../lib/commands/credits.js';
|
|
29
|
+
import * as airdropCommands from '../lib/commands/airdrop.js';
|
|
20
30
|
import { runInit } from '../lib/commands/init.js';
|
|
21
|
-
import * as folderCommands from '../lib/commands/folder.js';
|
|
22
31
|
import * as updateCommands from '../lib/commands/update.js';
|
|
32
|
+
import { registerAllRetiredKbSync, refuseRetiredKbSync, CANONICAL_KB_SYNC } from '../lib/commands/retired-kb-sync.js';
|
|
23
33
|
import { runStatus } from '../lib/commands/status.js';
|
|
24
34
|
import { runDoctor } from '../lib/commands/doctor.js';
|
|
35
|
+
import { runHealth } from '../lib/commands/health.js';
|
|
25
36
|
import * as kbCommands from '../lib/commands/kb.js';
|
|
37
|
+
import { kbVectorCell, kbCountSource } from '../lib/commands/kb-list-render.js';
|
|
38
|
+
import * as corpusCommands from '../lib/commands/corpus.js';
|
|
39
|
+
import * as modelConfigCommands from '../lib/commands/model-config.js';
|
|
40
|
+
import * as brieferCommand from '../lib/commands/briefer/index.js';
|
|
26
41
|
import fs from 'fs/promises';
|
|
27
42
|
import path from 'path';
|
|
28
43
|
import os from 'os';
|
|
29
44
|
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Emit human-facing PROGRESS for a command that can also emit JSON.
|
|
48
|
+
*
|
|
49
|
+
* When `--json` is set, stdout is a DATA CHANNEL: it must contain the JSON document and
|
|
50
|
+
* nothing else, or every scripted consumer breaks. Progress then goes to stderr, where a
|
|
51
|
+
* human piping stdout still sees it. Without `--json`, stdout is the human channel and
|
|
52
|
+
* progress belongs there.
|
|
53
|
+
*
|
|
54
|
+
* ONE OWNER of that decision. Before this existed, each --json command re-decided it by
|
|
55
|
+
* calling console.log directly, and both of them got it wrong: `mcp execute --json` prefixed
|
|
56
|
+
* the document with "Executing <tool>..." and `app media-upload --json` prefixed it with a
|
|
57
|
+
* header, a per-file listing and a per-file +/x line. Both exited 0, so a consumer saw a
|
|
58
|
+
* success code and an unparseable stream. Measured 2026-08-21/22 (seat BEAST): two seats had
|
|
59
|
+
* independently hand-rolled prefix-stripping workarounds (`tail -n +2`, `raw.find('[')`)
|
|
60
|
+
* rather than reporting it.
|
|
61
|
+
*
|
|
62
|
+
* @param {Object} options - the command's parsed options (read for .json)
|
|
63
|
+
* @param {...any} args - passed through to console.log / console.error
|
|
64
|
+
*/
|
|
65
|
+
function progress(options, ...args) {
|
|
66
|
+
if (options && options.json) console.error(...args);
|
|
67
|
+
else console.log(...args);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* THE ONE OWNER of "a command threw, and the process must stop saying why".
|
|
72
|
+
*
|
|
73
|
+
* WHY THIS EXISTS (measured 2026-08-30 on the SHIPPED artifact): 27 command actions ended in a
|
|
74
|
+
* bare `catch (error) { process.exit(1); }`. `DESCIX_API_URL=not-a-url descix credits balance`
|
|
75
|
+
* exited 1 with ZERO BYTES on stdout AND stderr — the origin owner had built an
|
|
76
|
+
* OriginInvalidError carrying the full remedy text and this handler threw it away. The contract
|
|
77
|
+
* row "a configured-but-invalid origin FAILS LOUD naming the fix" was met in the module and
|
|
78
|
+
* unmet on the artifact, because a message nobody prints is not a message.
|
|
79
|
+
*
|
|
80
|
+
* That is this contract's own bug class — silence — reaching the developer through the
|
|
81
|
+
* contract's own remedy path. A handler that discards the reason is indistinguishable from a
|
|
82
|
+
* crash, and it trains developers to believe the CLI has no error messages.
|
|
83
|
+
*
|
|
84
|
+
* @param {Error} error - the error that ended the command
|
|
85
|
+
*/
|
|
86
|
+
function fail(error) {
|
|
87
|
+
const message = (error && error.message) ? error.message : String(error);
|
|
88
|
+
console.error(chalk.red(`\n${message}\n`));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
30
92
|
const program = new Command();
|
|
31
93
|
|
|
32
94
|
program
|
|
33
95
|
.name('descix')
|
|
34
96
|
.description('DeSciX CLI - Unified command-line interface')
|
|
35
|
-
.version(
|
|
97
|
+
.version(CLI_VERSION)
|
|
98
|
+
.option('--env <name>', 'Target environment: dev, demo, prod (overrides API URL)')
|
|
99
|
+
.option('--api-url <url>', 'Direct API URL override (e.g., https://demo.descix.net)');
|
|
100
|
+
|
|
101
|
+
// ============ Global Environment Override ============
|
|
102
|
+
// Maps --env flag to DESCIX_API_URL before any command runs.
|
|
103
|
+
// detectApiUrl() in api-client.js checks process.env.DESCIX_API_URL first.
|
|
104
|
+
|
|
105
|
+
// Derive ephemeral --env URL map from the canonical WorkspaceConfig.ENV_MAP
|
|
106
|
+
const ENV_URL_MAP = Object.fromEntries(
|
|
107
|
+
Object.entries(WorkspaceConfig.ENV_MAP).map(([k, v]) => [k, v.url])
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
program.hook('preAction', (thisCommand) => {
|
|
111
|
+
const opts = thisCommand.opts();
|
|
112
|
+
|
|
113
|
+
// --api-url takes highest priority
|
|
114
|
+
if (opts.apiUrl) {
|
|
115
|
+
process.env.DESCIX_API_URL = opts.apiUrl;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --env maps to known URLs
|
|
120
|
+
if (opts.env) {
|
|
121
|
+
const envName = opts.env.toLowerCase();
|
|
122
|
+
if (!(envName in ENV_URL_MAP)) {
|
|
123
|
+
console.error(chalk.red(`Unknown environment: ${opts.env}. Use: dev, demo, prod`));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
// Every named environment resolves to a URL (including dev → cloud DEV).
|
|
127
|
+
// A local backend is a URL you name, not an environment.
|
|
128
|
+
process.env.DESCIX_API_URL = ENV_URL_MAP[envName];
|
|
129
|
+
}
|
|
130
|
+
});
|
|
36
131
|
|
|
37
132
|
// ============ Authentication Commands (No Auth Required) ============
|
|
38
133
|
|
|
@@ -41,33 +136,18 @@ program
|
|
|
41
136
|
.command('login')
|
|
42
137
|
.description('Authenticate with DeSciX via device login (opens browser)')
|
|
43
138
|
.option('-u, --url <url>', 'API URL override')
|
|
44
|
-
.option('--dev', 'Use development server (https://localhost:4000)')
|
|
45
139
|
.option('--wallet', 'Use direct wallet connection (advanced, not yet implemented)')
|
|
140
|
+
.option('--no-oauth', 'Skip the OAuth long-lived token leg (wallet-signature login only)')
|
|
141
|
+
.option('--scope <scope>', 'OAuth scope to request (default: mcp:read mcp:tools mcp:write mcp:admin)')
|
|
46
142
|
.action(async (options) => {
|
|
47
143
|
try {
|
|
48
|
-
// Handle --dev flag
|
|
49
|
-
if (options.dev) {
|
|
50
|
-
const workspaceConfig = await WorkspaceConfig.tryLoad(process.cwd());
|
|
51
|
-
if (workspaceConfig) {
|
|
52
|
-
workspaceConfig.apiUrl = 'https://localhost:4000';
|
|
53
|
-
workspaceConfig.environment = 'development';
|
|
54
|
-
await workspaceConfig.save(process.cwd());
|
|
55
|
-
} else {
|
|
56
|
-
const gc = await GlobalConfig.load();
|
|
57
|
-
gc.api_url = 'https://localhost:4000';
|
|
58
|
-
gc.environment = 'development';
|
|
59
|
-
await gc.save();
|
|
60
|
-
}
|
|
61
|
-
console.log(chalk.cyan('✓ Configured for development (https://localhost:4000)\n'));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
144
|
if (options.wallet) {
|
|
65
145
|
await authCommands.loginWallet();
|
|
66
146
|
} else {
|
|
67
147
|
await authCommands.loginDevice(options);
|
|
68
148
|
}
|
|
69
149
|
} catch (error) {
|
|
70
|
-
|
|
150
|
+
fail(error);
|
|
71
151
|
}
|
|
72
152
|
});
|
|
73
153
|
|
|
@@ -78,7 +158,20 @@ program
|
|
|
78
158
|
try {
|
|
79
159
|
await authCommands.logout();
|
|
80
160
|
} catch (error) {
|
|
81
|
-
|
|
161
|
+
fail(error);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
program
|
|
166
|
+
.command('admin-login')
|
|
167
|
+
.description('Bootstrap CLI credentials for platform admins (requires admin group membership)')
|
|
168
|
+
.requiredOption('-e, --email <email>', 'Admin email (must be in platform admin Google Group)')
|
|
169
|
+
.option('-u, --url <url>', 'API URL override')
|
|
170
|
+
.action(async (options) => {
|
|
171
|
+
try {
|
|
172
|
+
await authCommands.adminLogin(options);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
fail(error);
|
|
82
175
|
}
|
|
83
176
|
});
|
|
84
177
|
|
|
@@ -121,7 +214,7 @@ program
|
|
|
121
214
|
await requireAuth(apiClient);
|
|
122
215
|
await authCommands.whoami();
|
|
123
216
|
} catch (error) {
|
|
124
|
-
|
|
217
|
+
fail(error);
|
|
125
218
|
}
|
|
126
219
|
});
|
|
127
220
|
|
|
@@ -132,7 +225,7 @@ program
|
|
|
132
225
|
try {
|
|
133
226
|
await authCommands.reconnect();
|
|
134
227
|
} catch (error) {
|
|
135
|
-
|
|
228
|
+
fail(error);
|
|
136
229
|
}
|
|
137
230
|
});
|
|
138
231
|
|
|
@@ -161,6 +254,53 @@ program
|
|
|
161
254
|
}
|
|
162
255
|
});
|
|
163
256
|
|
|
257
|
+
program
|
|
258
|
+
.command('health')
|
|
259
|
+
.description('Check platform service health (env-aware: local-port in DEV, gcloud+HTTPS in DEMO/PROD)')
|
|
260
|
+
.option('--env <name>', 'Target environment: dev|demo|prod (default: dev). Also accepted at top level: descix --env=demo health ...')
|
|
261
|
+
.option('-m, --microservice <name>', 'Check a specific service/app by appId')
|
|
262
|
+
.option('-j, --json', 'Output raw JSON')
|
|
263
|
+
.action(async (options) => {
|
|
264
|
+
try {
|
|
265
|
+
// Resolve --env in priority order: subcommand explicit > program parent > 'dev'.
|
|
266
|
+
// Commander would otherwise silently default to 'dev' even when the parent
|
|
267
|
+
// --env=demo is supplied (because the subcommand owns its own flag space).
|
|
268
|
+
const parentEnv = program.opts().env || null;
|
|
269
|
+
const env = options.env || parentEnv || 'dev';
|
|
270
|
+
await runHealth({ ...options, env });
|
|
271
|
+
} catch (error) {
|
|
272
|
+
console.error(chalk.red(`\nHealth check error: ${error.message}\n`));
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ============ Briefer Command (Code-Grounded Mental Model Regen) ============
|
|
278
|
+
// WS-DESCIX-BRIEFER-CLI M1: CLI scaffolding + extractor contract.
|
|
279
|
+
// See DeSciX/DeSciX_Core/descix-cli/lib/commands/briefer/ for implementation.
|
|
280
|
+
// Scope doc: docs/design/ws-descix-briefer-cli.md
|
|
281
|
+
// Briefer target: DeSciX/V2_docs/architecture/platform-must-know-briefer.md
|
|
282
|
+
|
|
283
|
+
program
|
|
284
|
+
.command('briefer')
|
|
285
|
+
.description('Regenerate platform-must-know-briefer.md from live code + gcloud + Firestore (HARD-FAIL on drift)')
|
|
286
|
+
.option('--env <name>', 'Target environment: dev|demo|prod (also accepted at the top level: descix --env=demo briefer ...)')
|
|
287
|
+
.option('--out <path>', 'Override output path (default: workspace-root/DeSciX/V2_docs/architecture/platform-must-know-briefer.md)')
|
|
288
|
+
.option('--check', 'Drift-detection mode: regen to memory, diff against canonical, non-zero exit on drift')
|
|
289
|
+
.option('-v, --verbose', 'Print per-source paths, citations, and timings')
|
|
290
|
+
.action(async (options) => {
|
|
291
|
+
try {
|
|
292
|
+
// Resolve --env in priority order: subcommand explicit > program parent > 'dev'.
|
|
293
|
+
// Commander would otherwise silently default to 'dev' even when the parent
|
|
294
|
+
// --env=demo is supplied (because the subcommand owns its own flag space).
|
|
295
|
+
const parentEnv = program.opts().env || null;
|
|
296
|
+
const env = options.env || parentEnv || 'dev';
|
|
297
|
+
await brieferCommand.runBriefer({ ...options, env });
|
|
298
|
+
} catch (error) {
|
|
299
|
+
console.error(chalk.red(`\n❌ Briefer command failed: ${error.message}\n`));
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
164
304
|
// ============ Buy Commands (Crypto Payments) ============
|
|
165
305
|
|
|
166
306
|
const buyCommand = program
|
|
@@ -179,7 +319,7 @@ buyCommand
|
|
|
179
319
|
try {
|
|
180
320
|
await buyCommands.createQuote(options);
|
|
181
321
|
} catch (error) {
|
|
182
|
-
|
|
322
|
+
fail(error);
|
|
183
323
|
}
|
|
184
324
|
});
|
|
185
325
|
|
|
@@ -190,7 +330,7 @@ buyCommand
|
|
|
190
330
|
try {
|
|
191
331
|
await buyCommands.checkStatus(quoteId);
|
|
192
332
|
} catch (error) {
|
|
193
|
-
|
|
333
|
+
fail(error);
|
|
194
334
|
}
|
|
195
335
|
});
|
|
196
336
|
|
|
@@ -206,7 +346,7 @@ buyCommand
|
|
|
206
346
|
initialInterval: parseInt(options.interval)
|
|
207
347
|
});
|
|
208
348
|
} catch (error) {
|
|
209
|
-
|
|
349
|
+
fail(error);
|
|
210
350
|
}
|
|
211
351
|
});
|
|
212
352
|
|
|
@@ -217,195 +357,112 @@ buyCommand
|
|
|
217
357
|
buyCommands.listChains();
|
|
218
358
|
});
|
|
219
359
|
|
|
220
|
-
// ============
|
|
360
|
+
// ============ AI Credits Commands (WS-HEADLESS-MVP-A2, CEO-D-2026-07-01 D2) ============
|
|
361
|
+
// Platform-wide USD AI-credits: metered RAG/agent calls debit this balance.
|
|
362
|
+
// NOT community tokens (those are under `descix buy`).
|
|
221
363
|
|
|
222
|
-
const
|
|
223
|
-
.command('
|
|
224
|
-
.description('
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
.
|
|
229
|
-
.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
364
|
+
const creditsCommand = program
|
|
365
|
+
.command('credits')
|
|
366
|
+
.description('Platform-wide USD AI credits (metered AI consumption)');
|
|
367
|
+
|
|
368
|
+
creditsCommand
|
|
369
|
+
.command('balance')
|
|
370
|
+
.description('Show your AI-credits balance')
|
|
371
|
+
.action(async () => {
|
|
372
|
+
try {
|
|
373
|
+
await creditsCommands.showBalance();
|
|
374
|
+
} catch (error) {
|
|
375
|
+
fail(error);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
creditsCommand
|
|
380
|
+
.command('history')
|
|
381
|
+
.description('Show your credit ledger (purchases, debits, grants)')
|
|
382
|
+
.option('--limit <n>', 'Max entries (default 50)')
|
|
235
383
|
.action(async (options) => {
|
|
236
384
|
try {
|
|
237
|
-
|
|
238
|
-
await requireAuth(apiClient);
|
|
239
|
-
|
|
240
|
-
// Load workspace context
|
|
241
|
-
const workspaceConfig = await WorkspaceConfig.load();
|
|
242
|
-
const ctx = workspaceConfig.resolveContextWithOptions(options);
|
|
243
|
-
|
|
244
|
-
const communityId = ctx.communityId;
|
|
245
|
-
const appId = ctx.appId;
|
|
246
|
-
|
|
247
|
-
if (!communityId || !appId) {
|
|
248
|
-
console.error(chalk.red('\n❌ Community and App ID required.'));
|
|
249
|
-
console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
|
|
250
|
-
process.exit(1);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (options.status) {
|
|
254
|
-
console.log(chalk.cyan(`\n📊 Asset Sync Status: ${communityId}/${appId}\n`));
|
|
255
|
-
// TODO: Implement status check
|
|
256
|
-
console.log(chalk.gray(' Status check not yet implemented for assets.\n'));
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (options.pull) {
|
|
261
|
-
console.log(chalk.cyan(`\n📥 Pulling assets from Drive: ${communityId}/${appId}\n`));
|
|
262
|
-
// TODO: Implement pull
|
|
263
|
-
console.log(chalk.gray(' Pull not yet implemented. Use descix update app for push.\n'));
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Default: push
|
|
268
|
-
await updateCommands.updateApp(options);
|
|
269
|
-
|
|
385
|
+
await creditsCommands.showHistory(options);
|
|
270
386
|
} catch (error) {
|
|
271
|
-
|
|
272
|
-
process.exit(1);
|
|
387
|
+
fail(error);
|
|
273
388
|
}
|
|
274
389
|
});
|
|
275
390
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
.
|
|
279
|
-
.
|
|
280
|
-
|
|
281
|
-
.
|
|
282
|
-
.option('--
|
|
283
|
-
.
|
|
284
|
-
.option('-c, --community <id>', 'Community ID (uses context if not provided)')
|
|
285
|
-
.option('-a, --app <id>', 'App ID (uses context if not provided)')
|
|
286
|
-
.option('-k, --kb <name>', 'KB name (default: General)')
|
|
287
|
-
.action(async (stage, options) => {
|
|
391
|
+
creditsCommand
|
|
392
|
+
.command('buy')
|
|
393
|
+
.description('Buy AI credits with USD (Stripe checkout)')
|
|
394
|
+
.requiredOption('--usd <amount>', 'USD amount of credits to buy')
|
|
395
|
+
// The default is the origin this invocation is already talking to, NOT a fixed production
|
|
396
|
+
// host — see commands/credits.js. Naming a prod origin here made --help itself the misreport.
|
|
397
|
+
.option('--return-base <url>', 'Base URL for the checkout success/cancel landing (default: the API origin in use)')
|
|
398
|
+
.action(async (options) => {
|
|
288
399
|
try {
|
|
289
|
-
|
|
290
|
-
await requireAuth(apiClient);
|
|
291
|
-
|
|
292
|
-
// Load workspace context
|
|
293
|
-
const workspaceConfig = await WorkspaceConfig.load();
|
|
294
|
-
const ctx = workspaceConfig.resolveContextWithOptions(options);
|
|
295
|
-
|
|
296
|
-
const communityId = ctx.communityId;
|
|
297
|
-
const appId = ctx.appId;
|
|
298
|
-
const kbId = options.kb || ctx.kbId || 'General';
|
|
299
|
-
|
|
300
|
-
if (!communityId || !appId) {
|
|
301
|
-
console.error(chalk.red('\n❌ Community and App ID required.'));
|
|
302
|
-
console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
|
|
303
|
-
process.exit(1);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
if (options.status) {
|
|
307
|
-
console.log(chalk.cyan(`\n📊 KB Sync Status: ${communityId}/${appId}/${kbId}\n`));
|
|
308
|
-
// Call existing status logic
|
|
309
|
-
const stages = stage === 'stage1' ? [1] : stage === 'stage2' ? [2] : stage === 'stage3' ? [3] : [1, 2, 3];
|
|
310
|
-
|
|
311
|
-
for (const s of stages) {
|
|
312
|
-
if (s === 2) {
|
|
313
|
-
const response = await apiClient.invoke('get_drive_gcs_sync_status', {
|
|
314
|
-
community_id: communityId, app_id: appId, kb_id: kbId
|
|
315
|
-
});
|
|
316
|
-
const result = response.message || response;
|
|
317
|
-
console.log(chalk.yellow('Stage 2: Drive → GCS'));
|
|
318
|
-
console.log(chalk.gray(` To Extract: ${result.to_extract || 0}`));
|
|
319
|
-
console.log(chalk.gray(` In Sync: ${result.in_sync || 0}\n`));
|
|
320
|
-
}
|
|
321
|
-
if (s === 3) {
|
|
322
|
-
const response = await apiClient.invoke('get_gcs_pinecone_sync_status', {
|
|
323
|
-
community_id: communityId, app_id: appId, kb_id: kbId
|
|
324
|
-
});
|
|
325
|
-
const result = response.message || response;
|
|
326
|
-
console.log(chalk.yellow('Stage 3: GCS → Pinecone'));
|
|
327
|
-
console.log(chalk.gray(` To Vectorize: ${result.to_vectorize || 0}`));
|
|
328
|
-
console.log(chalk.gray(` Total Vectors: ${result.total_vectors || 0}\n`));
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Determine what to sync
|
|
335
|
-
const stage1Only = stage === 'stage1' || options.push;
|
|
336
|
-
const stageToRun = stage || 'all';
|
|
337
|
-
|
|
338
|
-
console.log(chalk.cyan(`\n📤 KB Sync: ${communityId}/${appId}/${kbId} (${stageToRun})\n`));
|
|
339
|
-
|
|
340
|
-
await updateCommands.updateKB({
|
|
341
|
-
...options,
|
|
342
|
-
kb: kbId,
|
|
343
|
-
stage1Only: stage1Only,
|
|
344
|
-
skipStage1: stage === 'stage2' || stage === 'stage3'
|
|
345
|
-
});
|
|
346
|
-
|
|
400
|
+
await creditsCommands.buyCredits({ usd: options.usd, returnBase: options.returnBase });
|
|
347
401
|
} catch (error) {
|
|
348
|
-
|
|
349
|
-
process.exit(1);
|
|
402
|
+
fail(error);
|
|
350
403
|
}
|
|
351
404
|
});
|
|
352
405
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
.
|
|
356
|
-
.
|
|
357
|
-
.
|
|
358
|
-
.
|
|
359
|
-
.option('--status', 'Show deployment status')
|
|
360
|
-
.option('--port <number>', 'Register local dev server port')
|
|
361
|
-
.option('-c, --community <id>', 'Community ID (uses context if not provided)')
|
|
362
|
-
.option('-a, --app <id>', 'App ID (uses context if not provided)')
|
|
406
|
+
creditsCommand
|
|
407
|
+
.command('grant')
|
|
408
|
+
.description('ADMIN: grant AI credits to a user')
|
|
409
|
+
.requiredOption('--user <user_id>', 'Target user id')
|
|
410
|
+
.requiredOption('--usd <amount>', 'USD amount to grant')
|
|
411
|
+
.requiredOption('--reason <text>', 'Audit reason')
|
|
363
412
|
.action(async (options) => {
|
|
364
413
|
try {
|
|
365
|
-
|
|
366
|
-
await requireAuth(apiClient);
|
|
367
|
-
|
|
368
|
-
// Load workspace context
|
|
369
|
-
const workspaceConfig = await WorkspaceConfig.load();
|
|
370
|
-
const ctx = workspaceConfig.resolveContextWithOptions(options);
|
|
371
|
-
|
|
372
|
-
const communityId = ctx.communityId;
|
|
373
|
-
const appId = ctx.appId;
|
|
374
|
-
|
|
375
|
-
if (!communityId || !appId) {
|
|
376
|
-
console.error(chalk.red('\n❌ Community and App ID required.'));
|
|
377
|
-
console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
|
|
378
|
-
process.exit(1);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
if (options.status) {
|
|
382
|
-
console.log(chalk.cyan(`\n📊 Site Status: ${communityId}/${appId}\n`));
|
|
383
|
-
const response = await apiClient.invoke('get_site_manifest', {
|
|
384
|
-
community_id: communityId, app_id: appId
|
|
385
|
-
});
|
|
386
|
-
const result = response.message || response;
|
|
387
|
-
if (result.manifest) {
|
|
388
|
-
console.log(chalk.gray(` Files: ${Object.keys(result.manifest.files || {}).length}`));
|
|
389
|
-
console.log(chalk.gray(` URL: ${result.site_url}\n`));
|
|
390
|
-
} else {
|
|
391
|
-
console.log(chalk.gray(' No site deployed.\n'));
|
|
392
|
-
}
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Default: push
|
|
397
|
-
await updateCommands.updateSite({
|
|
398
|
-
...options,
|
|
399
|
-
preview: options.preview,
|
|
400
|
-
port: options.port ? parseInt(options.port) : undefined
|
|
401
|
-
});
|
|
402
|
-
|
|
414
|
+
await creditsCommands.grantCredits(options);
|
|
403
415
|
} catch (error) {
|
|
404
|
-
|
|
405
|
-
|
|
416
|
+
fail(error);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
creditsCommand
|
|
421
|
+
.command('refund')
|
|
422
|
+
.description('ADMIN: remove AI credits from a user (e.g. after a Stripe refund)')
|
|
423
|
+
.requiredOption('--user <user_id>', 'Target user id')
|
|
424
|
+
.requiredOption('--usd <amount>', 'USD amount to remove')
|
|
425
|
+
.requiredOption('--reason <text>', 'Audit reason')
|
|
426
|
+
.action(async (options) => {
|
|
427
|
+
try {
|
|
428
|
+
await creditsCommands.refundCredits(options);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
fail(error);
|
|
406
431
|
}
|
|
407
432
|
});
|
|
408
433
|
|
|
434
|
+
// ============ Airdrop Commands (WS-ADMIN-B1 manual-trigger) ============
|
|
435
|
+
// Per CEO-D-MANUAL-TRIGGER-NO-CRON (2026-04-20), airdrop batch execution is operator-triggered
|
|
436
|
+
// via this CLI command group rather than Cloud Scheduler cron. Server-side access is gated on
|
|
437
|
+
// platform-admin membership (`isPlatformAdmin(user)`); the server emits an on_chain_log row
|
|
438
|
+
// with `caller.operator_email` for audit.
|
|
439
|
+
|
|
440
|
+
const airdropCommand = program
|
|
441
|
+
.command('airdrop')
|
|
442
|
+
.description('Admin airdrop migration operations (WS-ADMIN-B1)');
|
|
443
|
+
|
|
444
|
+
airdropCommand
|
|
445
|
+
.command('execute-queue')
|
|
446
|
+
.description('Manually trigger airdrop_execute_queue on the target env (admin-only)')
|
|
447
|
+
.option('--community <slug>', 'Community slug for per-community batch scoping (REQUIRED for --apply)')
|
|
448
|
+
.option('--dry-run', 'Read-only preview: encode calldata, estimate gas, validate net-zero invariant. No PK, no tx, no state mutation.')
|
|
449
|
+
.option('--apply', 'Live execution: requires --signer-pk-file or interactive prompt-password. Mutually exclusive with --dry-run.')
|
|
450
|
+
.option('--signer-pk-file <path>', 'Path to file containing admin signer PK (0x + 64 hex). Required for --apply unless prompted interactively.')
|
|
451
|
+
.option('--batch-size <n>', 'Cap on users processed this run (server caps at AIRDROP_MAX_RUN_USERS)')
|
|
452
|
+
.action(async (options) => {
|
|
453
|
+
try {
|
|
454
|
+
await airdropCommands.executeQueue(options);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
fail(error);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// ============ Sync Commands: REMOVED ============
|
|
461
|
+
// `sync assets`, `sync site` and `sync kb` are all gone, so the `sync` GROUP is gone with
|
|
462
|
+
// them: a registered verb with no working children is dead weight. Every retired surface is
|
|
463
|
+
// registered in ONE place below (search registerAllRetiredKbSync), driven off the owner list
|
|
464
|
+
// in lib/commands/retired-kb-sync.js -- this file names no retired verb itself.
|
|
465
|
+
|
|
409
466
|
// ============ Community/App Commands ============
|
|
410
467
|
|
|
411
468
|
const communityCommand = program
|
|
@@ -441,17 +498,23 @@ communityCommand
|
|
|
441
498
|
communityCommand
|
|
442
499
|
.command('info')
|
|
443
500
|
.description('Get community information')
|
|
444
|
-
.
|
|
501
|
+
.option('-c, --community <id>', 'Community ID')
|
|
502
|
+
.option('-n, --name <id>', 'Community ID (alias for -c)')
|
|
445
503
|
.action(async (options) => {
|
|
446
504
|
try {
|
|
505
|
+
const communityId = options.community || options.name;
|
|
506
|
+
if (!communityId) {
|
|
507
|
+
console.error(chalk.red('\n Error: Community ID is required. Use -c or -n to specify.\n'));
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
447
510
|
const apiClient = new DeSciXApiClient();
|
|
448
511
|
await requireAuth(apiClient);
|
|
449
|
-
|
|
450
|
-
const response = await apiClient.invoke('get_community', { community_id:
|
|
512
|
+
|
|
513
|
+
const response = await apiClient.invoke('get_community', { community_id: communityId }, { allowGuest: false });
|
|
451
514
|
const result = response.message || response;
|
|
452
515
|
const community = result.community;
|
|
453
|
-
|
|
454
|
-
console.log(chalk.green('\n
|
|
516
|
+
|
|
517
|
+
console.log(chalk.green('\n Community Information:\n'));
|
|
455
518
|
console.log(chalk.cyan(` Name: ${community.community_name}`));
|
|
456
519
|
console.log(chalk.gray(` ID: ${community.community_id}`));
|
|
457
520
|
console.log(chalk.gray(` Token: ${community.token_symbol}`));
|
|
@@ -502,90 +565,329 @@ communityCommand
|
|
|
502
565
|
|
|
503
566
|
communityCommand
|
|
504
567
|
.command('create')
|
|
505
|
-
.description('
|
|
506
|
-
.
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
console.log(chalk.gray(' descix-admin uses the same workspace root and .descix/wallet.json as this CLI.\n'));
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
// Deploy a token contract without creating community
|
|
514
|
-
communityCommand
|
|
515
|
-
.command('deploy-token')
|
|
516
|
-
.description('Deploy a new token contract (without creating a community)')
|
|
517
|
-
.requiredOption('-t, --token <symbol>', 'Token symbol (e.g., POWCH)')
|
|
518
|
-
.requiredOption('-n, --name <name>', 'Token name (e.g., "Powch Token")')
|
|
519
|
-
.option('--treasury <address>', 'Treasury wallet address (defaults to platform wallet)')
|
|
520
|
-
.option('-c, --community <id>', 'Community ID to link the token to (optional)')
|
|
568
|
+
.description('[ADMIN] Create a new community with token contract (requires platform admin, deploys to live Polygon)')
|
|
569
|
+
.requiredOption('-n, --name <name>', 'Community display name (e.g. "SMILE")')
|
|
570
|
+
.requiredOption('-t, --token <symbol>', 'Token symbol (e.g. SMILE)')
|
|
571
|
+
.option('--icon <url>', 'Icon URL for the community')
|
|
572
|
+
.option('--yes', 'Skip confirmation prompt (use with caution)')
|
|
521
573
|
.action(async (options) => {
|
|
522
574
|
try {
|
|
523
575
|
const apiClient = new DeSciXApiClient();
|
|
524
576
|
await requireAuth(apiClient);
|
|
525
|
-
|
|
577
|
+
|
|
578
|
+
const communityId = options.token.toLowerCase();
|
|
526
579
|
const tokenSymbol = options.token.toUpperCase();
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
console.log(chalk.
|
|
530
|
-
console.log(chalk.
|
|
531
|
-
console.log(chalk.
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
|
|
580
|
+
|
|
581
|
+
console.log(chalk.cyan('\n📦 Community Creation [ADMIN ONLY]\n'));
|
|
582
|
+
console.log(chalk.yellow(' ⚠ WARNING: This operation is IRREVERSIBLE.'));
|
|
583
|
+
console.log(chalk.yellow(' ⚠ DEV/DEMO environments use the LIVE Polygon blockchain.'));
|
|
584
|
+
console.log(chalk.yellow(' ⚠ A real token contract will be deployed on-chain.\n'));
|
|
585
|
+
console.log(chalk.white(` Community ID: ${communityId}`));
|
|
586
|
+
console.log(chalk.white(` Community Name: ${options.name}`));
|
|
587
|
+
console.log(chalk.white(` Token Symbol: ${tokenSymbol}`));
|
|
588
|
+
console.log(chalk.white(` Network: Polygon (live)\n`));
|
|
589
|
+
|
|
590
|
+
if (!options.yes) {
|
|
591
|
+
const readline = await import('readline');
|
|
592
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
593
|
+
const answer = await new Promise(resolve => {
|
|
594
|
+
rl.question(chalk.yellow(` Create community "${options.name}" with token ${tokenSymbol} on live Polygon? [y/N] `), resolve);
|
|
595
|
+
});
|
|
596
|
+
rl.close();
|
|
597
|
+
if (answer.toLowerCase() !== 'y') {
|
|
598
|
+
console.log(chalk.gray('\n Aborted.\n'));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
537
601
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
602
|
+
|
|
603
|
+
console.log(chalk.gray('\n Creating community skeleton...'));
|
|
604
|
+
|
|
605
|
+
const response = await apiClient.invoke('create_community_skeleton', {
|
|
606
|
+
community_id: communityId,
|
|
607
|
+
community_name: options.name,
|
|
544
608
|
token_symbol: tokenSymbol,
|
|
545
|
-
|
|
546
|
-
treasury_wallet: options.treasury || null,
|
|
547
|
-
community_id: options.community || null
|
|
609
|
+
icon_url: options.icon || null,
|
|
548
610
|
});
|
|
549
|
-
|
|
550
|
-
// Handle response wrapping
|
|
611
|
+
|
|
551
612
|
const result = response.message || response;
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
console.log(chalk.green('✅ Token contract deployed successfully!\n'));
|
|
560
|
-
console.log(chalk.white('Contract Details:'));
|
|
561
|
-
console.log(chalk.gray(` Proxy Address: ${deployment.proxy_address}`));
|
|
562
|
-
console.log(chalk.gray(` Implementation: ${deployment.implementation_address}`));
|
|
563
|
-
console.log(chalk.gray(` Token Symbol: ${deployment.token_symbol}`));
|
|
564
|
-
console.log(chalk.gray(` Token Name: ${deployment.token_name}`));
|
|
565
|
-
console.log(chalk.gray(` Network: ${deployment.network} (chainId: ${deployment.chain_id})`));
|
|
566
|
-
console.log(chalk.gray(` TX Hash: ${deployment.tx_hash}`));
|
|
567
|
-
console.log();
|
|
568
|
-
|
|
569
|
-
if (deployment.network === 'polygon') {
|
|
570
|
-
console.log(chalk.cyan(`View on Polygonscan: https://polygonscan.com/address/${deployment.proxy_address}`));
|
|
571
|
-
} else if (deployment.network === 'amoy') {
|
|
572
|
-
console.log(chalk.cyan(`View on Amoy: https://amoy.polygonscan.com/address/${deployment.proxy_address}`));
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
if (!options.community) {
|
|
576
|
-
console.log(chalk.yellow('\nNext steps:'));
|
|
577
|
-
console.log(chalk.gray(` 1. Create community: descix community create -n "Name" -t ${tokenSymbol} -p ./path --no-token-contract`));
|
|
578
|
-
console.log(chalk.gray(` 2. Link token: descix community link-token -c <community-id> -a ${deployment.proxy_address}`));
|
|
613
|
+
|
|
614
|
+
console.log(chalk.green(`\n ✓ Community "${options.name}" created\n`));
|
|
615
|
+
console.log(chalk.white(` Community ID: ${result.community_id}`));
|
|
616
|
+
console.log(chalk.white(` Default App: ${result.app_id}`));
|
|
617
|
+
console.log(chalk.white(` Token Symbol: ${result.token_symbol}`));
|
|
618
|
+
if (result.contract_address) {
|
|
619
|
+
console.log(chalk.white(` Contract: ${result.contract_address}`));
|
|
579
620
|
}
|
|
580
|
-
|
|
581
|
-
|
|
621
|
+
|
|
622
|
+
console.log(chalk.cyan('\n Next steps:'));
|
|
623
|
+
console.log(chalk.gray(` descix app init -a ${result.app_id} # Initialize KB`));
|
|
624
|
+
console.log(chalk.gray(` descix kb corpus sync -a ${result.app_id} # Sync content`));
|
|
625
|
+
console.log(chalk.gray(` descix site upload -c ${communityId} -a ${result.app_id} -p ./site # Deploy site\n`));
|
|
626
|
+
|
|
582
627
|
} catch (error) {
|
|
583
|
-
console.error(chalk.red(`\n
|
|
628
|
+
console.error(chalk.red(`\n Error: ${error.message}\n`));
|
|
584
629
|
process.exit(1);
|
|
585
630
|
}
|
|
586
631
|
});
|
|
587
632
|
|
|
588
|
-
|
|
633
|
+
communityCommand
|
|
634
|
+
.command('delete')
|
|
635
|
+
.description('[ADMIN] Delete a community with full cascade cleanup (all apps, KBs, vectors, GCS)')
|
|
636
|
+
.requiredOption('-n, --name <community_id>', 'Community ID to delete')
|
|
637
|
+
.option('--dry-run', 'Preview what would be deleted without executing')
|
|
638
|
+
.option('--soft', 'Soft delete only (mark as hidden, no cascade)')
|
|
639
|
+
.option('--yes', 'Skip confirmation prompt')
|
|
640
|
+
.action(async (options) => {
|
|
641
|
+
try {
|
|
642
|
+
const apiClient = new DeSciXApiClient();
|
|
643
|
+
await requireAuth(apiClient);
|
|
644
|
+
|
|
645
|
+
const communityId = options.name;
|
|
646
|
+
const hardDelete = !options.soft;
|
|
647
|
+
const dryRun = !!options.dryRun;
|
|
648
|
+
|
|
649
|
+
if (dryRun) {
|
|
650
|
+
console.log(chalk.cyan(`\n--- DRY RUN: delete community ${communityId} ---\n`));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Always call with dry_run first to get the manifest for display
|
|
654
|
+
const previewResponse = await apiClient.invoke('delete_community', {
|
|
655
|
+
community_id: communityId,
|
|
656
|
+
hard_delete: hardDelete,
|
|
657
|
+
dry_run: true
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
const preview = previewResponse.message || previewResponse;
|
|
661
|
+
const manifest = preview.community_manifest;
|
|
662
|
+
|
|
663
|
+
if (!manifest) {
|
|
664
|
+
console.error(chalk.red('\n Error: Could not retrieve community information.\n'));
|
|
665
|
+
process.exit(1);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Display manifest
|
|
669
|
+
console.log(chalk.bold('Community deletion manifest:\n'));
|
|
670
|
+
console.log(chalk.gray(` Community: ${manifest.community_name} (${manifest.community_id})`));
|
|
671
|
+
console.log(chalk.gray(` Token: ${manifest.token_symbol}`));
|
|
672
|
+
console.log(chalk.gray(` Apps: ${manifest.app_count}`));
|
|
673
|
+
if (manifest.apps && manifest.apps.length > 0) {
|
|
674
|
+
for (const appM of manifest.apps) {
|
|
675
|
+
if (appM.error) {
|
|
676
|
+
console.log(chalk.red(` - ${appM.app_id} (error: ${appM.error})`));
|
|
677
|
+
} else {
|
|
678
|
+
const kbCount = appM.pinecone_kb_count || 0;
|
|
679
|
+
console.log(chalk.gray(` - ${appM.app_id}: ${kbCount} KB(s), GCS: ${appM.gcs_prefix || 'N/A'}, Products: ${appM.products_doc ? 'yes' : 'no'}`));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
console.log(chalk.gray(` Mode: ${hardDelete ? 'HARD DELETE (permanent)' : 'soft delete (hide only)'}`));
|
|
684
|
+
|
|
685
|
+
if (dryRun) {
|
|
686
|
+
console.log(chalk.yellow('\n No changes were made. Remove --dry-run to execute.\n'));
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Confirmation prompt (unless --yes)
|
|
691
|
+
if (!options.yes && hardDelete) {
|
|
692
|
+
const readline = await import('readline');
|
|
693
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
694
|
+
const answer = await new Promise(resolve => {
|
|
695
|
+
rl.question(chalk.yellow(`\n Permanently delete community "${manifest.community_name}" and all ${manifest.app_count} app(s)? This cannot be undone. [y/N] `), resolve);
|
|
696
|
+
});
|
|
697
|
+
rl.close();
|
|
698
|
+
if (answer.toLowerCase() !== 'y') {
|
|
699
|
+
console.log(chalk.gray('\n Aborted.\n'));
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Execute the actual delete
|
|
705
|
+
const response = await apiClient.invoke('delete_community', {
|
|
706
|
+
community_id: communityId,
|
|
707
|
+
hard_delete: hardDelete,
|
|
708
|
+
dry_run: false
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
const result = response.message || response;
|
|
712
|
+
|
|
713
|
+
if (result.cleanup) {
|
|
714
|
+
const c = result.cleanup;
|
|
715
|
+
console.log(chalk.green(`\n Community ${communityId} deleted.\n`));
|
|
716
|
+
console.log(chalk.gray(` Apps deleted: ${c.apps_deleted?.length || 0} (${c.apps_deleted?.join(', ') || 'none'})`));
|
|
717
|
+
if (c.apps_failed?.length > 0) {
|
|
718
|
+
console.log(chalk.red(` Apps failed: ${c.apps_failed.length}`));
|
|
719
|
+
for (const f of c.apps_failed) {
|
|
720
|
+
console.log(chalk.red(` - ${f.app_id}: ${f.error}`));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
console.log(chalk.gray(` Pinecone vectors: ${c.total_pinecone_deleted}`));
|
|
724
|
+
console.log(chalk.gray(` GCS files: ${c.total_gcs_deleted}`));
|
|
725
|
+
console.log(chalk.gray(` Firestore docs: ${c.total_firestore_deleted}`));
|
|
726
|
+
console.log(chalk.gray(` Products entries: ${c.total_products_deleted}`));
|
|
727
|
+
console.log(chalk.gray(` ServiceManifests: ${c.total_service_manifests_deleted}`));
|
|
728
|
+
console.log();
|
|
729
|
+
} else {
|
|
730
|
+
console.log(chalk.green(`\n ${result.message || `Community ${communityId} deleted.`}\n`));
|
|
731
|
+
}
|
|
732
|
+
} catch (error) {
|
|
733
|
+
console.error(chalk.red(`\n Error: ${error.message}\n`));
|
|
734
|
+
process.exit(1);
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
communityCommand
|
|
739
|
+
.command('rename')
|
|
740
|
+
.description('[ADMIN] Rename a community_id across all surfaces (Firestore, Products, Pinecone metadata, ServiceManifests, descix-chain registry). On-chain contract + token symbol are UNTOUCHED. Dry-run first.')
|
|
741
|
+
.argument('<old_community_id>', 'Current community_id (e.g. unkamon)')
|
|
742
|
+
.argument('<new_community_id>', 'New canonical community_id (e.g. unk)')
|
|
743
|
+
.option('--dry-run', 'Preview the full cascade plan without executing')
|
|
744
|
+
.option('--yes', 'Skip confirmation prompt')
|
|
745
|
+
.action(async (oldId, newId, options) => {
|
|
746
|
+
try {
|
|
747
|
+
const apiClient = new DeSciXApiClient();
|
|
748
|
+
await requireAuth(apiClient);
|
|
749
|
+
const dryRun = !!options.dryRun;
|
|
750
|
+
|
|
751
|
+
// Always fetch the plan first (dry_run:true) for display.
|
|
752
|
+
const previewResp = await apiClient.invoke('rename_community', {
|
|
753
|
+
old_community_id: oldId, new_community_id: newId, dry_run: true
|
|
754
|
+
});
|
|
755
|
+
const preview = previewResp.message || previewResp;
|
|
756
|
+
const plan = preview.plan;
|
|
757
|
+
if (!plan) {
|
|
758
|
+
console.error(chalk.red('\n Error: could not retrieve rename plan.\n'));
|
|
759
|
+
process.exit(1);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
console.log(chalk.bold(`\nCommunity rename plan: ${oldId} -> ${newId}\n`));
|
|
763
|
+
console.log(chalk.gray(` Community doc: old_exists=${plan.community_doc.old_exists}, new_exists=${plan.community_doc.new_exists}`));
|
|
764
|
+
console.log(chalk.gray(` Name / token: ${plan.community_doc.community_name} / ${plan.community_doc.token_symbol}`));
|
|
765
|
+
console.log(chalk.gray(` Apps subcollection: ${plan.community_doc.apps_subcollection.join(', ') || '(none)'}`));
|
|
766
|
+
console.log(chalk.gray(` Roles subcollection: ${plan.community_doc.roles_subcollection.join(', ') || '(none)'}`));
|
|
767
|
+
console.log(chalk.gray(` Community tree docs: ${plan.community_doc.total_docs_in_tree}`));
|
|
768
|
+
console.log(chalk.gray(` Products to update: ${plan.products.count} (${plan.products.app_ids.join(', ')})`));
|
|
769
|
+
console.log(chalk.gray(` User purchases: ${plan.user_purchases_to_repoint}`));
|
|
770
|
+
console.log(chalk.gray(` ServiceManifests: ${plan.service_manifests_to_retag.join(', ') || '(none)'}`));
|
|
771
|
+
console.log(chalk.gray(` Chain product/contract: ${plan.chain.product_doc_old || '(none)'} / ${plan.chain.contract_address || '(none)'} (symbol ${plan.chain.token_symbol}, communityId ${plan.chain.contract_community_id})`));
|
|
772
|
+
console.log(chalk.cyan(` Pinecone re-tag: scanned=${plan.pinecone.scanned}, matched(community=${oldId})=${plan.pinecone.matched}, legacy-id-prefixed=${plan.pinecone.legacyIdMatched}`));
|
|
773
|
+
console.log(chalk.gray(` by app: ${JSON.stringify(plan.pinecone.byApp)}`));
|
|
774
|
+
console.log(chalk.gray(` prefix counts: ${JSON.stringify(plan.pinecone.prefixCounts)}`));
|
|
775
|
+
|
|
776
|
+
if (dryRun) {
|
|
777
|
+
console.log(chalk.yellow('\n DRY RUN — no changes made. Remove --dry-run to execute.\n'));
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (!options.yes) {
|
|
782
|
+
const readline = await import('readline');
|
|
783
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
784
|
+
const answer = await new Promise(resolve => rl.question(
|
|
785
|
+
chalk.yellow(`\n Execute rename ${oldId} -> ${newId}? Re-tags ${plan.pinecone.matched} vector(s) and deletes Community/${oldId} after verifying Community/${newId}. [y/N] `),
|
|
786
|
+
resolve
|
|
787
|
+
));
|
|
788
|
+
rl.close();
|
|
789
|
+
if (answer.toLowerCase() !== 'y') {
|
|
790
|
+
console.log(chalk.gray('\n Aborted.\n'));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const resp = await apiClient.invoke('rename_community', {
|
|
796
|
+
old_community_id: oldId, new_community_id: newId, dry_run: false
|
|
797
|
+
});
|
|
798
|
+
const result = resp.message || resp;
|
|
799
|
+
const ex = result.executed || {};
|
|
800
|
+
console.log(chalk.green(`\n ${result.message}\n`));
|
|
801
|
+
console.log(chalk.gray(` Community doc copied: ${ex.community_doc_copied}`));
|
|
802
|
+
console.log(chalk.gray(` Old community deleted: ${ex.old_community_deleted}`));
|
|
803
|
+
console.log(chalk.gray(` Products updated: ${ex.products_updated}`));
|
|
804
|
+
console.log(chalk.gray(` User purchases: ${ex.user_purchases_repointed}`));
|
|
805
|
+
console.log(chalk.gray(` Pinecone re-tagged: ${ex.pinecone?.updated} / matched ${ex.pinecone?.matched}`));
|
|
806
|
+
console.log(chalk.gray(` ServiceManifests: ${ex.service_manifests_retagged}`));
|
|
807
|
+
console.log(chalk.gray(` Chain re-pointed: ${ex.chain_repointed} (contract ${ex.chain_contract_address}, symbol unchanged)`));
|
|
808
|
+
console.log();
|
|
809
|
+
} catch (error) {
|
|
810
|
+
console.error(chalk.red(`\n Error: ${error.message}\n`));
|
|
811
|
+
process.exit(1);
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// Deploy a token contract without creating community
|
|
816
|
+
communityCommand
|
|
817
|
+
.command('deploy-token')
|
|
818
|
+
.description('Deploy a new token contract (without creating a community)')
|
|
819
|
+
.requiredOption('-t, --token <symbol>', 'Token symbol (e.g., POWCH)')
|
|
820
|
+
.requiredOption('-n, --name <name>', 'Token name (e.g., "Powch Token")')
|
|
821
|
+
.option('--treasury <address>', 'Treasury wallet address (defaults to platform wallet)')
|
|
822
|
+
.option('-c, --community <id>', 'Community ID to link the token to (optional)')
|
|
823
|
+
.action(async (options) => {
|
|
824
|
+
try {
|
|
825
|
+
const apiClient = new DeSciXApiClient();
|
|
826
|
+
await requireAuth(apiClient);
|
|
827
|
+
|
|
828
|
+
const tokenSymbol = options.token.toUpperCase();
|
|
829
|
+
const tokenName = options.name;
|
|
830
|
+
|
|
831
|
+
console.log(chalk.cyan('\n📦 Deploying token contract...\n'));
|
|
832
|
+
console.log(chalk.gray(` Token Symbol: ${tokenSymbol}`));
|
|
833
|
+
console.log(chalk.gray(` Token Name: ${tokenName}`));
|
|
834
|
+
if (options.treasury) {
|
|
835
|
+
console.log(chalk.gray(` Treasury: ${options.treasury}`));
|
|
836
|
+
}
|
|
837
|
+
if (options.community) {
|
|
838
|
+
console.log(chalk.gray(` Community: ${options.community}`));
|
|
839
|
+
}
|
|
840
|
+
console.log();
|
|
841
|
+
|
|
842
|
+
console.log(chalk.yellow('⏳ This may take a few minutes while transactions confirm on-chain...\n'));
|
|
843
|
+
|
|
844
|
+
const response = await apiClient.invoke('deploy_contract', {
|
|
845
|
+
template_type: 'DAITA_v2',
|
|
846
|
+
token_symbol: tokenSymbol,
|
|
847
|
+
token_name: tokenName,
|
|
848
|
+
treasury_wallet: options.treasury || null,
|
|
849
|
+
community_id: options.community || null
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
// Handle response wrapping
|
|
853
|
+
const result = response.message || response;
|
|
854
|
+
|
|
855
|
+
if (result.status !== 'OK') {
|
|
856
|
+
throw new Error(result.message || 'Deployment failed');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const deployment = result.deployment;
|
|
860
|
+
|
|
861
|
+
console.log(chalk.green('✅ Token contract deployed successfully!\n'));
|
|
862
|
+
console.log(chalk.white('Contract Details:'));
|
|
863
|
+
console.log(chalk.gray(` Proxy Address: ${deployment.proxy_address}`));
|
|
864
|
+
console.log(chalk.gray(` Implementation: ${deployment.implementation_address}`));
|
|
865
|
+
console.log(chalk.gray(` Token Symbol: ${deployment.token_symbol}`));
|
|
866
|
+
console.log(chalk.gray(` Token Name: ${deployment.token_name}`));
|
|
867
|
+
console.log(chalk.gray(` Network: ${deployment.network} (chainId: ${deployment.chain_id})`));
|
|
868
|
+
console.log(chalk.gray(` TX Hash: ${deployment.tx_hash}`));
|
|
869
|
+
console.log();
|
|
870
|
+
|
|
871
|
+
if (deployment.network === 'polygon') {
|
|
872
|
+
console.log(chalk.cyan(`View on Polygonscan: https://polygonscan.com/address/${deployment.proxy_address}`));
|
|
873
|
+
} else if (deployment.network === 'amoy') {
|
|
874
|
+
console.log(chalk.cyan(`View on Amoy: https://amoy.polygonscan.com/address/${deployment.proxy_address}`));
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (!options.community) {
|
|
878
|
+
console.log(chalk.yellow('\nNext steps:'));
|
|
879
|
+
console.log(chalk.gray(` 1. Create community: descix community create -n "Name" -t ${tokenSymbol}`));
|
|
880
|
+
console.log(chalk.gray(` 2. Link token: descix community link-token -c <community-id> -a ${deployment.proxy_address}`));
|
|
881
|
+
}
|
|
882
|
+
console.log();
|
|
883
|
+
|
|
884
|
+
} catch (error) {
|
|
885
|
+
console.error(chalk.red(`\n❌ Error: ${error.message}`));
|
|
886
|
+
process.exit(1);
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
// Link existing token contract to community
|
|
589
891
|
communityCommand
|
|
590
892
|
.command('link-token')
|
|
591
893
|
.description('Link an existing token contract to a community')
|
|
@@ -743,39 +1045,64 @@ appCommand
|
|
|
743
1045
|
|
|
744
1046
|
appCommand
|
|
745
1047
|
.command('init')
|
|
746
|
-
|
|
747
|
-
|
|
1048
|
+
// ONE PATH TO AN APP (CEO-D-2026-08-14): `app init` is THE app entry point — it creates the
|
|
1049
|
+
// app when it does not exist yet and then registers/scaffolds it locally. The separate
|
|
1050
|
+
// `app create` command is DELETED (no compat fence): two commands for one outcome is how apps
|
|
1051
|
+
// ended up half-made — created on the platform, never initialized — which is precisely the
|
|
1052
|
+
// state the DEV audit found (powch, sml, daita-ssgpod carrying zero knowledge bases).
|
|
1053
|
+
// Creation still routes through the single canonical server command create_app_for_community,
|
|
1054
|
+
// which composes the app_id and fails loud on a missing community / duplicate id / bad short
|
|
1055
|
+
// name. The server also guarantees the default KB, so no app can exist without one.
|
|
1056
|
+
.description('Create (if needed) and initialize an app: platform record + default KB + local workspace registration and scaffold. Idempotent. Pass -c to create a new app; omit it to initialize one that already exists.')
|
|
1057
|
+
.requiredOption('-a, --app <app_id>', 'App ID (e.g. daita). With -c this is the app NAME to create.')
|
|
1058
|
+
.option('-c, --community <id>', 'Community ID — REQUIRED to create an app that does not exist yet. The community must already be materialized in this environment.')
|
|
1059
|
+
.option('-s, --short <short_name>', 'SHORT id segment (no hyphens) used when creating; the app_id is composed server-side as {community}-{short}. Defaults to --app.')
|
|
1060
|
+
.option('--overwrite', 'When creating, overwrite an existing app record intentionally')
|
|
748
1061
|
.option('--kb <name>', 'Knowledge base name', 'General')
|
|
749
1062
|
.option('-p, --path <dir>', 'Local app directory (default: auto-detected or cwd)')
|
|
750
|
-
.option('-c, --community <community_id>', 'Community to create app in (if not yet in Products registry)')
|
|
751
1063
|
.action(async (options) => {
|
|
752
1064
|
try {
|
|
753
1065
|
const apiClient = new DeSciXApiClient();
|
|
754
1066
|
await requireAuth(apiClient);
|
|
755
|
-
|
|
1067
|
+
let appId = options.app;
|
|
756
1068
|
const kbId = options.kb || 'General';
|
|
757
1069
|
|
|
758
|
-
// Resolve community_id from Products registry
|
|
1070
|
+
// Resolve community_id from the Products registry to learn whether the app exists yet.
|
|
759
1071
|
let communityId;
|
|
760
1072
|
try {
|
|
761
1073
|
const productCtx = await apiClient.invoke('get_product_context', { app_id: appId });
|
|
762
1074
|
communityId = (productCtx.message || productCtx).community_id;
|
|
763
1075
|
} catch (e) {
|
|
764
|
-
//
|
|
1076
|
+
// Not in Products — either we create it below (-c given) or we fail loud.
|
|
765
1077
|
}
|
|
766
|
-
|
|
1078
|
+
|
|
767
1079
|
if (!communityId) {
|
|
768
|
-
if (options.community) {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
});
|
|
774
|
-
communityId = options.community;
|
|
775
|
-
console.log(chalk.green(` ✓ Created App + Products + Purchase for ${appId}`));
|
|
776
|
-
} else {
|
|
777
|
-
throw new Error(`App '${appId}' not found in Products registry. Use --community to create it.`);
|
|
1080
|
+
if (!options.community) {
|
|
1081
|
+
throw new Error(
|
|
1082
|
+
`App '${appId}' does not exist yet. Pass -c <community> to create it: ` +
|
|
1083
|
+
`descix app init -a ${appId} -c <community> [-s <short>]`
|
|
1084
|
+
);
|
|
778
1085
|
}
|
|
1086
|
+
// CREATE leg — the canonical server path. It composes the unique app_id
|
|
1087
|
+
// ({community}-{short}) and is authoritative; we never compose it client-side.
|
|
1088
|
+
const response = await apiClient.invoke('create_app_for_community', {
|
|
1089
|
+
community_id: options.community,
|
|
1090
|
+
app_name: appId,
|
|
1091
|
+
short_name: options.short || undefined,
|
|
1092
|
+
create_skeleton: false,
|
|
1093
|
+
overwrite: options.overwrite,
|
|
1094
|
+
});
|
|
1095
|
+
const created = response.message || response;
|
|
1096
|
+
appId = created.app_id || appId;
|
|
1097
|
+
communityId = created.community_id || options.community;
|
|
1098
|
+
console.log(chalk.green(`\n✅ App created: ${appId}`));
|
|
1099
|
+
console.log(chalk.gray(` Products: Products/${appId}`));
|
|
1100
|
+
console.log(chalk.gray(` Firestore: Community/${communityId}/Apps/${appId}`));
|
|
1101
|
+
} else if (options.community && options.community !== communityId) {
|
|
1102
|
+
throw new Error(
|
|
1103
|
+
`App '${appId}' already exists in community '${communityId}', not '${options.community}'. ` +
|
|
1104
|
+
`Omit -c to initialize the existing app.`
|
|
1105
|
+
);
|
|
779
1106
|
}
|
|
780
1107
|
|
|
781
1108
|
// 1. Workspace.json — register app if not already mapped
|
|
@@ -783,6 +1110,13 @@ appCommand
|
|
|
783
1110
|
const alreadyMapped = workspaceConfig?.getAppByAppId(appId);
|
|
784
1111
|
let appPath = alreadyMapped?.absolutePath;
|
|
785
1112
|
|
|
1113
|
+
if (alreadyMapped && options.path) {
|
|
1114
|
+
throw new Error(
|
|
1115
|
+
`App '${appId}' is already mapped to '${alreadyMapped.localPath}'. ` +
|
|
1116
|
+
`Use 'descix app set-localpath -a ${appId} -p <new-path>' to update.`
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
786
1120
|
if (!alreadyMapped) {
|
|
787
1121
|
const localPath = options.path || '.';
|
|
788
1122
|
const wsRoot = workspaceConfig?.workspaceRoot || process.cwd();
|
|
@@ -793,15 +1127,29 @@ appCommand
|
|
|
793
1127
|
console.log(chalk.gray(` workspace.json updated: ${appId} → ${localPath}`));
|
|
794
1128
|
}
|
|
795
1129
|
|
|
796
|
-
// 2. Create
|
|
1130
|
+
// 2. Create app folder structure (site, microservice, assets)
|
|
797
1131
|
if (appPath) {
|
|
798
1132
|
const siteDir = path.join(appPath, 'site');
|
|
799
|
-
const kbDir = path.join(appPath, 'kb', kbId);
|
|
800
1133
|
const msDir = path.join(appPath, 'microservice');
|
|
1134
|
+
const assetsDir = path.join(appPath, 'assets');
|
|
801
1135
|
await fs.mkdir(siteDir, { recursive: true });
|
|
802
|
-
await fs.mkdir(kbDir, { recursive: true });
|
|
803
1136
|
await fs.mkdir(msDir, { recursive: true });
|
|
804
|
-
|
|
1137
|
+
await fs.mkdir(assetsDir, { recursive: true });
|
|
1138
|
+
|
|
1139
|
+
// Create template asset files if they don't exist
|
|
1140
|
+
const siPath = path.join(assetsDir, 'system_instructions.md');
|
|
1141
|
+
const descPath = path.join(assetsDir, 'app_description.md');
|
|
1142
|
+
try {
|
|
1143
|
+
await fs.access(siPath);
|
|
1144
|
+
} catch {
|
|
1145
|
+
await fs.writeFile(siPath, `# System Instructions for ${appId}\n\nYou are an AI assistant for the ${appId} application.\n`);
|
|
1146
|
+
}
|
|
1147
|
+
try {
|
|
1148
|
+
await fs.access(descPath);
|
|
1149
|
+
} catch {
|
|
1150
|
+
await fs.writeFile(descPath, `# ${appId}\n\nApplication description goes here.\n`);
|
|
1151
|
+
}
|
|
1152
|
+
console.log(chalk.gray(` Created: site/, microservice/, assets/`));
|
|
805
1153
|
}
|
|
806
1154
|
|
|
807
1155
|
// 3. Create KnowledgeBase Firestore doc (Git Mode — no Drive required)
|
|
@@ -811,9 +1159,12 @@ appCommand
|
|
|
811
1159
|
console.log(chalk.green(`\n✓ ${appId} initialized`));
|
|
812
1160
|
console.log(chalk.gray(` Community: ${communityId}`));
|
|
813
1161
|
console.log(chalk.gray(` KB: ${kbId} — ${kbResult.created ? 'created' : 'already exists'}\n`));
|
|
1162
|
+
console.log(chalk.gray(` (every app is guaranteed a default KB at creation; an empty one`));
|
|
1163
|
+
console.log(chalk.gray(` says so rather than answering from general knowledge)\n`));
|
|
814
1164
|
console.log(chalk.cyan('Next steps:'));
|
|
815
|
-
console.log(chalk.gray(`
|
|
816
|
-
console.log(chalk.
|
|
1165
|
+
console.log(chalk.gray(` Create a corpus manifest at apps/${appId}/.descix/manifests/${kbId}.json`));
|
|
1166
|
+
console.log(chalk.gray(` then run:`));
|
|
1167
|
+
console.log(chalk.white(` descix kb corpus sync -a ${appId}\n`));
|
|
817
1168
|
} catch (error) {
|
|
818
1169
|
console.error(chalk.red(error.message));
|
|
819
1170
|
process.exit(1);
|
|
@@ -821,111 +1172,153 @@ appCommand
|
|
|
821
1172
|
});
|
|
822
1173
|
|
|
823
1174
|
appCommand
|
|
824
|
-
.command('
|
|
825
|
-
|
|
826
|
-
.
|
|
827
|
-
.
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
console.log(chalk.green('\n✅ Folder registered successfully!\n'));
|
|
843
|
-
console.log(chalk.cyan(` Folder ID: ${result.folder_id}`));
|
|
844
|
-
console.log(chalk.gray(` Folder Name: ${result.folder_name}\n`));
|
|
845
|
-
} catch (error) {
|
|
846
|
-
console.error(chalk.red(error.message));
|
|
847
|
-
process.exit(1);
|
|
848
|
-
}
|
|
1175
|
+
.command('create')
|
|
1176
|
+
// DELETED SURFACE (CEO-D-2026-08-14, SUPER-DRY: delete the old path, fail loud, no compat
|
|
1177
|
+
// fence). `app create` created the platform record and STOPPED, leaving the caller to
|
|
1178
|
+
// remember `app init` — two commands for one outcome. That is how apps ended up half-made
|
|
1179
|
+
// with no knowledge base at all. `app init` now creates AND initializes in one path.
|
|
1180
|
+
.description('[REMOVED] Use `descix app init -a <name> -c <community>` — it creates and initializes in one step.')
|
|
1181
|
+
// Swallow whatever the old invocation passed so the caller always gets the pointer below
|
|
1182
|
+
// rather than a generic commander arity error that names no replacement.
|
|
1183
|
+
.allowUnknownOption(true)
|
|
1184
|
+
.argument('[ignored...]')
|
|
1185
|
+
.action(() => {
|
|
1186
|
+
console.error(chalk.red('\n❌ `descix app create` has been removed.\n'));
|
|
1187
|
+
console.error(chalk.white(' Use the single canonical path, which creates AND initializes:\n'));
|
|
1188
|
+
console.error(chalk.cyan(' descix app init -a <app-name> -c <community> [-s <short>]\n'));
|
|
1189
|
+
console.error(chalk.gray(' It registers the app (Products + Firestore + entitlement), guarantees'));
|
|
1190
|
+
console.error(chalk.gray(' the default knowledge base, then registers and scaffolds it locally.'));
|
|
1191
|
+
console.error(chalk.gray(' Creating an app without a default KB is no longer possible.\n'));
|
|
1192
|
+
process.exit(1);
|
|
849
1193
|
});
|
|
850
1194
|
|
|
1195
|
+
// descix app media-upload — upload media/asset files to an app's GCS assets prefix via the
|
|
1196
|
+
// API surface (WS-V1-PURGE Phase 1, item 2; media-via-API-surface PLATFORM half).
|
|
1197
|
+
// This is the canonical, filesystem-free way to get app media (podcast audio, cover art,
|
|
1198
|
+
// etc.) into the platform: request a short-lived signed PUT token from the API surface
|
|
1199
|
+
// (`get_asset_upload_token` over /apifront, user-session authed), upload each file straight
|
|
1200
|
+
// to GCS, and print the ASSET REFERENCES. An app microservice then fetches an uploaded asset
|
|
1201
|
+
// by reference over the Core broker via `get_app_asset` (no shared local filesystem needed).
|
|
851
1202
|
appCommand
|
|
852
|
-
.command('
|
|
853
|
-
.description('
|
|
854
|
-
.
|
|
855
|
-
.
|
|
856
|
-
.option('--
|
|
857
|
-
.option('--
|
|
1203
|
+
.command('media-upload')
|
|
1204
|
+
.description('Upload media/asset files to an app\'s GCS assets prefix via the API surface (returns asset references)')
|
|
1205
|
+
.requiredOption('-a, --app <id>', 'App ID (community is resolved server-side from Products)')
|
|
1206
|
+
.requiredOption('-f, --file <path...>', 'One or more local file paths to upload')
|
|
1207
|
+
.option('--prefix <relPath>', 'Optional sub-path under the app assets/ prefix (e.g. "shows/myshow")', '')
|
|
1208
|
+
.option('--json', 'Print the asset references as JSON (for scripting)')
|
|
858
1209
|
.action(async (options) => {
|
|
859
1210
|
try {
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
await requireAuth(apiClient);
|
|
1211
|
+
const apiClient = new DeSciXApiClient();
|
|
1212
|
+
await requireAuth(apiClient);
|
|
863
1213
|
|
|
864
|
-
|
|
865
|
-
|
|
1214
|
+
const path = await import('path');
|
|
1215
|
+
const mime = (await import('mime-types')).default;
|
|
1216
|
+
|
|
1217
|
+
const appId = options.app;
|
|
1218
|
+
const localFiles = Array.isArray(options.file) ? options.file : [options.file];
|
|
1219
|
+
const subPrefix = (options.prefix || '').replace(/^\/+|\/+$/g, '');
|
|
1220
|
+
|
|
1221
|
+
// Build the upload descriptor list: object path under assets/ = [subPrefix/]basename.
|
|
1222
|
+
const fileDescriptors = [];
|
|
1223
|
+
for (const localPath of localFiles) {
|
|
1224
|
+
const abs = path.resolve(localPath);
|
|
1225
|
+
let stat;
|
|
866
1226
|
try {
|
|
867
|
-
|
|
1227
|
+
stat = await fs.stat(abs);
|
|
868
1228
|
} catch {
|
|
869
|
-
console.error(chalk.red(
|
|
1229
|
+
console.error(chalk.red(`\n❌ File not found: ${abs}\n`));
|
|
870
1230
|
process.exit(1);
|
|
871
1231
|
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
});
|
|
886
|
-
});
|
|
1232
|
+
if (!stat.isFile()) {
|
|
1233
|
+
console.error(chalk.red(`\n❌ Not a file: ${abs}\n`));
|
|
1234
|
+
process.exit(1);
|
|
1235
|
+
}
|
|
1236
|
+
const base = path.basename(abs);
|
|
1237
|
+
const objectPath = subPrefix ? `${subPrefix}/${base}` : base;
|
|
1238
|
+
fileDescriptors.push({
|
|
1239
|
+
path: objectPath,
|
|
1240
|
+
content_type: mime.lookup(abs) || 'application/octet-stream',
|
|
1241
|
+
size: stat.size,
|
|
1242
|
+
_absolutePath: abs
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
887
1245
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
process.exit(1);
|
|
891
|
-
}
|
|
1246
|
+
console.log(chalk.cyan(`\n Media Upload: ${appId} → GCS assets/\n`));
|
|
1247
|
+
fileDescriptors.forEach(f => progress(options, chalk.gray(` • ${f.path} (${(f.size / 1024).toFixed(1)}KB, ${f.content_type})`)));
|
|
892
1248
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1249
|
+
// 1. Request a signed-PUT upload token over the API surface.
|
|
1250
|
+
const tokenResponse = await apiClient.invoke('get_asset_upload_token', {
|
|
1251
|
+
app_id: appId,
|
|
1252
|
+
files: fileDescriptors.map(f => ({ path: f.path, content_type: f.content_type, size: f.size }))
|
|
1253
|
+
});
|
|
1254
|
+
const token = tokenResponse.message || tokenResponse;
|
|
1255
|
+
const { signed_urls, objects } = token;
|
|
1256
|
+
|
|
1257
|
+
// 2. PUT each file directly to GCS using its signed URL.
|
|
1258
|
+
progress(options, chalk.gray(`\n Uploading ${fileDescriptors.length} file(s)...`));
|
|
1259
|
+
const uploaded = [];
|
|
1260
|
+
const errors = [];
|
|
1261
|
+
for (const f of fileDescriptors) {
|
|
1262
|
+
const signedUrl = signed_urls?.[f.path];
|
|
1263
|
+
if (!signedUrl) {
|
|
1264
|
+
errors.push(`No signed URL for: ${f.path}`);
|
|
1265
|
+
progress(options, chalk.red(` x ${f.path}`));
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
try {
|
|
1269
|
+
const content = await fs.readFile(f._absolutePath);
|
|
1270
|
+
const resp = await fetch(signedUrl, {
|
|
1271
|
+
method: 'PUT',
|
|
1272
|
+
headers: { 'Content-Type': f.content_type },
|
|
1273
|
+
body: content
|
|
896
1274
|
});
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1275
|
+
if (!resp.ok) {
|
|
1276
|
+
errors.push(`Failed ${f.path}: ${resp.status} ${resp.statusText}`);
|
|
1277
|
+
progress(options, chalk.red(` x ${f.path}`));
|
|
1278
|
+
} else {
|
|
1279
|
+
const obj = (objects || []).find(o => o.path === f.path) || {};
|
|
1280
|
+
// The asset REFERENCE an app handler / get_app_asset consumes: the gs:// URI.
|
|
1281
|
+
const gcsRef = obj.gcs_path
|
|
1282
|
+
? `gs://${token.bucket}/${obj.gcs_path}`
|
|
1283
|
+
: null;
|
|
1284
|
+
uploaded.push({
|
|
1285
|
+
path: f.path,
|
|
1286
|
+
ref: gcsRef,
|
|
1287
|
+
gcs_path: obj.gcs_path || null,
|
|
1288
|
+
public_url: obj.public_url || null,
|
|
1289
|
+
content_type: f.content_type,
|
|
1290
|
+
size: f.size
|
|
1291
|
+
});
|
|
1292
|
+
progress(options, chalk.green(` + ${f.path}`));
|
|
1293
|
+
}
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
errors.push(`Error ${f.path}: ${err.message}`);
|
|
1296
|
+
progress(options, chalk.red(` x ${f.path}`));
|
|
904
1297
|
}
|
|
1298
|
+
}
|
|
905
1299
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1300
|
+
if (errors.length > 0) {
|
|
1301
|
+
progress(options, chalk.yellow(`\n ${errors.length} error(s):`));
|
|
1302
|
+
errors.forEach(e => progress(options, chalk.red(` - ${e}`)));
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (uploaded.length === 0) {
|
|
1306
|
+
console.error(chalk.red('\n❌ No files uploaded.\n'));
|
|
1307
|
+
process.exit(1);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (options.json) {
|
|
1311
|
+
console.log('\n' + JSON.stringify({ app_id: appId, assets: uploaded }, null, 2) + '\n');
|
|
1312
|
+
} else {
|
|
1313
|
+
console.log(chalk.green(`\n ✅ Uploaded ${uploaded.length} asset(s).\n`));
|
|
1314
|
+
console.log(chalk.cyan(' Asset references (pass these to your app handler; the service fetches via get_app_asset):'));
|
|
1315
|
+
uploaded.forEach(u => {
|
|
1316
|
+
console.log(chalk.white(` ${u.path}`));
|
|
1317
|
+
console.log(chalk.gray(` ref: ${u.ref}`));
|
|
1318
|
+
console.log(chalk.gray(` public_url: ${u.public_url}`));
|
|
911
1319
|
});
|
|
912
|
-
|
|
913
|
-
console.log(chalk.green('\n✅ App created successfully!\n'));
|
|
914
|
-
console.log(chalk.cyan(` App ID: ${result.app_id}`));
|
|
915
|
-
console.log(chalk.cyan(` Community: ${result.community_id}`));
|
|
916
|
-
console.log(chalk.gray(` Products: Products/${result.app_id}`));
|
|
917
|
-
console.log(chalk.gray(` Firestore: Community/${result.community_id}/Apps/${result.app_id}`));
|
|
918
|
-
if (result.skeleton_result) {
|
|
919
|
-
console.log(chalk.gray(` Drive: ${result.skeleton_result.folder_id || 'created'}`));
|
|
920
|
-
}
|
|
921
|
-
console.log(chalk.cyan('\n Next: descix app init -a ' + result.app_id + '\n'));
|
|
922
|
-
return;
|
|
1320
|
+
console.log();
|
|
923
1321
|
}
|
|
924
|
-
console.log(chalk.cyan('\n📦 App creation\n'));
|
|
925
|
-
console.log(chalk.white('Create apps in the PWA (Device Setup / App Manager) or via CLI:\n'));
|
|
926
|
-
console.log(chalk.white(' descix app create --quick -c <community> -a <app-name>\n'));
|
|
927
|
-
console.log(chalk.gray('This registers the app in the Products registry, creates Firestore'));
|
|
928
|
-
console.log(chalk.gray('documents, grants entitlement, and creates Drive skeleton.\n'));
|
|
929
1322
|
} catch (error) {
|
|
930
1323
|
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
931
1324
|
process.exit(1);
|
|
@@ -933,353 +1326,27 @@ appCommand
|
|
|
933
1326
|
});
|
|
934
1327
|
|
|
935
1328
|
appCommand
|
|
936
|
-
.command('
|
|
937
|
-
.description('
|
|
938
|
-
.
|
|
939
|
-
.
|
|
940
|
-
.requiredOption('-
|
|
941
|
-
.option('-k, --kb <id>', 'Knowledge Base ID', 'General')
|
|
1329
|
+
.command('set-codesite')
|
|
1330
|
+
.description('Set CodeSite URL/path for an app (use "descix site servelocal" for local dev port)')
|
|
1331
|
+
.option('-c, --community <id>', 'Community ID (uses context if not provided)')
|
|
1332
|
+
.option('-a, --app <id>', 'App ID (uses context if not provided)')
|
|
1333
|
+
.requiredOption('-u, --url <url>', 'CodeSite URL (GCS path or HTTPS URL)')
|
|
942
1334
|
.action(async (options) => {
|
|
943
1335
|
try {
|
|
944
1336
|
const apiClient = new DeSciXApiClient();
|
|
945
1337
|
await requireAuth(apiClient);
|
|
946
1338
|
|
|
947
|
-
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
// Read files from local path
|
|
951
|
-
const localPath = options.path;
|
|
952
|
-
const stats = await fs.stat(localPath);
|
|
953
|
-
if (!stats.isDirectory()) {
|
|
954
|
-
throw new Error(`Path is not a directory: ${localPath}`);
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
console.log(chalk.gray(`Reading files from ${localPath}...`));
|
|
958
|
-
|
|
959
|
-
// Recursively read files
|
|
960
|
-
const files = [];
|
|
961
|
-
async function readDir(dirPath, basePath = '') {
|
|
962
|
-
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
963
|
-
for (const entry of entries) {
|
|
964
|
-
const fullPath = path.join(dirPath, entry.name);
|
|
965
|
-
const relPath = path.join(basePath, entry.name);
|
|
966
|
-
|
|
967
|
-
if (entry.isDirectory()) {
|
|
968
|
-
await readDir(fullPath, relPath);
|
|
969
|
-
} else if (entry.isFile()) {
|
|
970
|
-
const content = await fs.readFile(fullPath, 'utf-8');
|
|
971
|
-
files.push({
|
|
972
|
-
name: entry.name,
|
|
973
|
-
path: relPath,
|
|
974
|
-
content: Buffer.from(content).toString('base64') // Base64 encode
|
|
975
|
-
});
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
}
|
|
1339
|
+
// Load workspace context
|
|
1340
|
+
const workspaceConfig = await WorkspaceConfig.load();
|
|
1341
|
+
const ctx = workspaceConfig.resolveContextWithOptions(options);
|
|
979
1342
|
|
|
980
|
-
|
|
1343
|
+
const communityId = ctx.communityId;
|
|
1344
|
+
const appId = ctx.appId;
|
|
981
1345
|
|
|
982
|
-
if (
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
console.log(chalk.gray(`Found ${files.length} files. Uploading...`));
|
|
987
|
-
|
|
988
|
-
const response = await apiClient.invoke('upload_files_to_kb', {
|
|
989
|
-
community_id: options.community,
|
|
990
|
-
app_id: options.app,
|
|
991
|
-
kb_id: options.kb,
|
|
992
|
-
files: files
|
|
993
|
-
});
|
|
994
|
-
const result = response.message || response;
|
|
995
|
-
|
|
996
|
-
console.log(chalk.green('\n✅ Files uploaded successfully!\n'));
|
|
997
|
-
console.log(chalk.cyan(` Uploaded: ${result.uploaded || 0} files`));
|
|
998
|
-
console.log(chalk.gray(` Total Vectors: ${result.totalVectors || 0}\n`));
|
|
999
|
-
|
|
1000
|
-
if (result.errors && result.errors.length > 0) {
|
|
1001
|
-
console.log(chalk.yellow(' Errors:'));
|
|
1002
|
-
result.errors.forEach(err => {
|
|
1003
|
-
console.log(chalk.red(` - ${err.file}: ${err.error}`));
|
|
1004
|
-
});
|
|
1005
|
-
}
|
|
1006
|
-
} catch (error) {
|
|
1007
|
-
console.error(chalk.red(error.message));
|
|
1008
|
-
process.exit(1);
|
|
1009
|
-
}
|
|
1010
|
-
});
|
|
1011
|
-
|
|
1012
|
-
appCommand
|
|
1013
|
-
.command('upload-tree')
|
|
1014
|
-
.description('Upload folder tree with subfolders as KBs')
|
|
1015
|
-
.requiredOption('-c, --community <id>', 'Community ID')
|
|
1016
|
-
.requiredOption('-a, --app <id>', 'App ID')
|
|
1017
|
-
.requiredOption('-p, --path <localPath>', 'Local folder path')
|
|
1018
|
-
.option('--confirm', 'Confirm each subfolder as KB')
|
|
1019
|
-
.option('--dry-run', 'Show what would be uploaded without uploading')
|
|
1020
|
-
.action(async (options) => {
|
|
1021
|
-
try {
|
|
1022
|
-
const apiClient = new DeSciXApiClient();
|
|
1023
|
-
await requireAuth(apiClient);
|
|
1024
|
-
|
|
1025
|
-
const fs = await import('fs/promises');
|
|
1026
|
-
const path = await import('path');
|
|
1027
|
-
const readline = await import('readline');
|
|
1028
|
-
|
|
1029
|
-
const localPath = options.path;
|
|
1030
|
-
const stats = await fs.stat(localPath);
|
|
1031
|
-
if (!stats.isDirectory()) {
|
|
1032
|
-
throw new Error(`Path is not a directory: ${localPath}`);
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
console.log(chalk.gray(`Scanning ${localPath}...\n`));
|
|
1036
|
-
|
|
1037
|
-
// Find top-level subdirectories
|
|
1038
|
-
const entries = await fs.readdir(localPath, { withFileTypes: true });
|
|
1039
|
-
const subdirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.'));
|
|
1040
|
-
const rootFiles = entries.filter(e => e.isFile() && !e.name.startsWith('.'));
|
|
1041
|
-
|
|
1042
|
-
if (subdirs.length === 0) {
|
|
1043
|
-
throw new Error('No subdirectories found. Use "app upload" for single KB upload.');
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
console.log(chalk.cyan(`Found ${subdirs.length} subdirectories:\n`));
|
|
1047
|
-
|
|
1048
|
-
// Count files in each subdirectory
|
|
1049
|
-
const subdirInfo = [];
|
|
1050
|
-
for (const dir of subdirs) {
|
|
1051
|
-
const dirPath = path.join(localPath, dir.name);
|
|
1052
|
-
const files = await readDirectoryFiles(dirPath);
|
|
1053
|
-
subdirInfo.push({
|
|
1054
|
-
name: dir.name,
|
|
1055
|
-
path: dirPath,
|
|
1056
|
-
fileCount: files.length
|
|
1057
|
-
});
|
|
1058
|
-
console.log(chalk.gray(` [${subdirInfo.length}] ${dir.name}/ (${files.length} files)`));
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
if (rootFiles.length > 0) {
|
|
1062
|
-
console.log(chalk.yellow(`\n Note: ${rootFiles.length} root-level files will go to "General" KB\n`));
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
if (options.dryRun) {
|
|
1066
|
-
console.log(chalk.cyan('\n✅ Dry run complete. Use without --dry-run to upload.\n'));
|
|
1067
|
-
return;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
// Confirm each subdirectory if --confirm flag
|
|
1071
|
-
const rl = readline.createInterface({
|
|
1072
|
-
input: process.stdin,
|
|
1073
|
-
output: process.stdout
|
|
1074
|
-
});
|
|
1075
|
-
|
|
1076
|
-
const askYesNo = (question, defaultYes = true) => {
|
|
1077
|
-
return new Promise((resolve) => {
|
|
1078
|
-
const suffix = defaultYes ? '(Y/n)' : '(y/N)';
|
|
1079
|
-
rl.question(`${question} ${suffix}: `, (answer) => {
|
|
1080
|
-
const trimmed = answer.trim().toLowerCase();
|
|
1081
|
-
resolve(trimmed === '' ? defaultYes : (trimmed === 'y' || trimmed === 'yes'));
|
|
1082
|
-
});
|
|
1083
|
-
});
|
|
1084
|
-
};
|
|
1085
|
-
|
|
1086
|
-
const kbsToUpload = [];
|
|
1087
|
-
for (const info of subdirInfo) {
|
|
1088
|
-
if (options.confirm) {
|
|
1089
|
-
const proceed = await askYesNo(`Create KB "${info.name}" and upload ${info.fileCount} files?`, true);
|
|
1090
|
-
if (!proceed) {
|
|
1091
|
-
console.log(chalk.gray(` Skipping ${info.name}\n`));
|
|
1092
|
-
continue;
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
kbsToUpload.push(info);
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
rl.close();
|
|
1099
|
-
|
|
1100
|
-
if (kbsToUpload.length === 0) {
|
|
1101
|
-
console.log(chalk.yellow('\n⚠️ No KBs selected for upload.\n'));
|
|
1102
|
-
return;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
// Upload each subdirectory as a KB
|
|
1106
|
-
for (const info of kbsToUpload) {
|
|
1107
|
-
console.log(chalk.cyan(`\n📦 Uploading to KB "${info.name}"...`));
|
|
1108
|
-
|
|
1109
|
-
// Create KB if it doesn't exist
|
|
1110
|
-
try {
|
|
1111
|
-
await apiClient.invoke('create_skeleton_kb', {
|
|
1112
|
-
community_id: options.community,
|
|
1113
|
-
app_id: options.app,
|
|
1114
|
-
kb_name: info.name
|
|
1115
|
-
});
|
|
1116
|
-
console.log(chalk.gray(` ✓ KB "${info.name}" created`));
|
|
1117
|
-
} catch (err) {
|
|
1118
|
-
// KB might already exist, continue
|
|
1119
|
-
if (!err.message.includes('already exists')) {
|
|
1120
|
-
console.log(chalk.yellow(` ⚠️ Could not create KB: ${err.message}`));
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
// Read files from subdirectory
|
|
1125
|
-
const files = await readDirectoryFiles(info.path);
|
|
1126
|
-
|
|
1127
|
-
if (files.length === 0) {
|
|
1128
|
-
console.log(chalk.yellow(` ⚠️ No files found in ${info.name}/`));
|
|
1129
|
-
continue;
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
// Upload in batches
|
|
1133
|
-
const BATCH_SIZE = 10;
|
|
1134
|
-
let uploaded = 0;
|
|
1135
|
-
let errors = [];
|
|
1136
|
-
|
|
1137
|
-
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
1138
|
-
const batch = files.slice(i, i + BATCH_SIZE);
|
|
1139
|
-
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
|
|
1140
|
-
const totalBatches = Math.ceil(files.length / BATCH_SIZE);
|
|
1141
|
-
|
|
1142
|
-
try {
|
|
1143
|
-
process.stdout.write(chalk.gray(` Batch ${batchNum}/${totalBatches}...`));
|
|
1144
|
-
const response = await apiClient.invoke('upload_files_to_kb', {
|
|
1145
|
-
community_id: options.community,
|
|
1146
|
-
app_id: options.app,
|
|
1147
|
-
kb_id: info.name,
|
|
1148
|
-
files: batch
|
|
1149
|
-
});
|
|
1150
|
-
const result = response.message || response;
|
|
1151
|
-
uploaded += result.uploaded || batch.length;
|
|
1152
|
-
if (result.errors) {
|
|
1153
|
-
errors.push(...result.errors);
|
|
1154
|
-
}
|
|
1155
|
-
console.log(chalk.green(' ✓'));
|
|
1156
|
-
} catch (err) {
|
|
1157
|
-
console.log(chalk.red(` ✗ ${err.message}`));
|
|
1158
|
-
errors.push({ batch: batchNum, error: err.message });
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
console.log(chalk.green(` ✅ Uploaded ${uploaded} files to KB "${info.name}"`));
|
|
1163
|
-
if (errors.length > 0) {
|
|
1164
|
-
console.log(chalk.yellow(` ⚠️ ${errors.length} errors during upload`));
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
// Handle root files if any
|
|
1169
|
-
if (rootFiles.length > 0) {
|
|
1170
|
-
console.log(chalk.cyan(`\n📦 Uploading ${rootFiles.length} root files to "General" KB...`));
|
|
1171
|
-
const rootFilesData = [];
|
|
1172
|
-
for (const file of rootFiles) {
|
|
1173
|
-
const filePath = path.join(localPath, file.name);
|
|
1174
|
-
const content = await fs.readFile(filePath);
|
|
1175
|
-
rootFilesData.push({
|
|
1176
|
-
name: file.name,
|
|
1177
|
-
path: file.name,
|
|
1178
|
-
content: content.toString('base64')
|
|
1179
|
-
});
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
try {
|
|
1183
|
-
const response = await apiClient.invoke('upload_files_to_kb', {
|
|
1184
|
-
community_id: options.community,
|
|
1185
|
-
app_id: options.app,
|
|
1186
|
-
kb_id: 'General',
|
|
1187
|
-
files: rootFilesData
|
|
1188
|
-
});
|
|
1189
|
-
const result = response.message || response;
|
|
1190
|
-
console.log(chalk.green(` ✅ Uploaded ${result.uploaded || 0} root files`));
|
|
1191
|
-
} catch (err) {
|
|
1192
|
-
console.log(chalk.yellow(` ⚠️ Could not upload root files: ${err.message}`));
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
console.log(chalk.green('\n✅ Upload tree complete!\n'));
|
|
1197
|
-
|
|
1198
|
-
// Helper function to read directory files
|
|
1199
|
-
async function readDirectoryFiles(dirPath, basePath = '') {
|
|
1200
|
-
const files = [];
|
|
1201
|
-
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
1202
|
-
|
|
1203
|
-
for (const entry of entries) {
|
|
1204
|
-
const fullPath = path.join(dirPath, entry.name);
|
|
1205
|
-
const relPath = path.join(basePath, entry.name);
|
|
1206
|
-
|
|
1207
|
-
if (entry.isDirectory()) {
|
|
1208
|
-
const subFiles = await readDirectoryFiles(fullPath, relPath);
|
|
1209
|
-
files.push(...subFiles);
|
|
1210
|
-
} else if (entry.isFile()) {
|
|
1211
|
-
// Skip hidden files
|
|
1212
|
-
if (entry.name.startsWith('.')) {
|
|
1213
|
-
continue;
|
|
1214
|
-
}
|
|
1215
|
-
const content = await fs.readFile(fullPath);
|
|
1216
|
-
files.push({
|
|
1217
|
-
name: entry.name,
|
|
1218
|
-
path: relPath,
|
|
1219
|
-
content: content.toString('base64')
|
|
1220
|
-
});
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
return files;
|
|
1225
|
-
}
|
|
1226
|
-
} catch (error) {
|
|
1227
|
-
console.error(chalk.red(error.message));
|
|
1228
|
-
process.exit(1);
|
|
1229
|
-
}
|
|
1230
|
-
});
|
|
1231
|
-
|
|
1232
|
-
appCommand
|
|
1233
|
-
.command('sync')
|
|
1234
|
-
.description('Sync knowledge base (vectorize files in Drive folder)')
|
|
1235
|
-
.requiredOption('-c, --community <id>', 'Community ID')
|
|
1236
|
-
.requiredOption('-a, --app <id>', 'App ID')
|
|
1237
|
-
.option('-k, --kb <id>', 'Knowledge Base ID', 'General')
|
|
1238
|
-
.action(async (options) => {
|
|
1239
|
-
try {
|
|
1240
|
-
const apiClient = new DeSciXApiClient();
|
|
1241
|
-
await requireAuth(apiClient);
|
|
1242
|
-
|
|
1243
|
-
const response = await apiClient.invoke('sync_knowledge_base', {
|
|
1244
|
-
community_id: options.community,
|
|
1245
|
-
app_id: options.app,
|
|
1246
|
-
kb_id: options.kb
|
|
1247
|
-
});
|
|
1248
|
-
const result = response.message || response;
|
|
1249
|
-
|
|
1250
|
-
console.log(chalk.green('\n✅ Sync initiated!\n'));
|
|
1251
|
-
console.log(chalk.cyan(` KB: ${options.community}/${options.app}/${options.kb}`));
|
|
1252
|
-
if (result.note) {
|
|
1253
|
-
console.log(chalk.gray(` Note: ${result.note}\n`));
|
|
1254
|
-
}
|
|
1255
|
-
} catch (error) {
|
|
1256
|
-
console.error(chalk.red(error.message));
|
|
1257
|
-
process.exit(1);
|
|
1258
|
-
}
|
|
1259
|
-
});
|
|
1260
|
-
|
|
1261
|
-
appCommand
|
|
1262
|
-
.command('set-codesite')
|
|
1263
|
-
.description('Set CodeSite URL/path for an app (use "descix site servelocal" for local dev port)')
|
|
1264
|
-
.option('-c, --community <id>', 'Community ID (uses context if not provided)')
|
|
1265
|
-
.option('-a, --app <id>', 'App ID (uses context if not provided)')
|
|
1266
|
-
.requiredOption('-u, --url <url>', 'CodeSite URL (GCS path or HTTPS URL)')
|
|
1267
|
-
.action(async (options) => {
|
|
1268
|
-
try {
|
|
1269
|
-
const apiClient = new DeSciXApiClient();
|
|
1270
|
-
await requireAuth(apiClient);
|
|
1271
|
-
|
|
1272
|
-
// Load workspace context
|
|
1273
|
-
const workspaceConfig = await WorkspaceConfig.load();
|
|
1274
|
-
const ctx = workspaceConfig.resolveContextWithOptions(options);
|
|
1275
|
-
|
|
1276
|
-
const communityId = ctx.communityId;
|
|
1277
|
-
const appId = ctx.appId;
|
|
1278
|
-
|
|
1279
|
-
if (!communityId || !appId) {
|
|
1280
|
-
console.error(chalk.red('\n❌ Community and App ID required.'));
|
|
1281
|
-
console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
|
|
1282
|
-
process.exit(1);
|
|
1346
|
+
if (!communityId || !appId) {
|
|
1347
|
+
console.error(chalk.red('\n❌ Community and App ID required.'));
|
|
1348
|
+
console.log(chalk.gray(' Either provide -c and -a flags, or cd into an app directory\n'));
|
|
1349
|
+
process.exit(1);
|
|
1283
1350
|
}
|
|
1284
1351
|
|
|
1285
1352
|
const response = await apiClient.invoke('update_app_metadata', {
|
|
@@ -1331,33 +1398,26 @@ appCommand
|
|
|
1331
1398
|
});
|
|
1332
1399
|
|
|
1333
1400
|
appCommand
|
|
1334
|
-
.command('set-
|
|
1335
|
-
.description('Set
|
|
1401
|
+
.command('set-name')
|
|
1402
|
+
.description('Set app display name')
|
|
1336
1403
|
.requiredOption('-c, --community <id>', 'Community ID')
|
|
1337
1404
|
.requiredOption('-a, --app <id>', 'App ID')
|
|
1338
|
-
.requiredOption('
|
|
1405
|
+
.requiredOption('--name <name>', 'Display name for the app')
|
|
1339
1406
|
.action(async (options) => {
|
|
1340
1407
|
try {
|
|
1341
1408
|
const apiClient = new DeSciXApiClient();
|
|
1342
1409
|
await requireAuth(apiClient);
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
try {
|
|
1346
|
-
new URL(options.url);
|
|
1347
|
-
} catch {
|
|
1348
|
-
throw new Error('Invalid URL format');
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
const response = await apiClient.invoke('update_app_metadata', {
|
|
1410
|
+
|
|
1411
|
+
const response = await apiClient.invoke('update_app', {
|
|
1352
1412
|
community_id: options.community,
|
|
1353
1413
|
app_id: options.app,
|
|
1354
|
-
|
|
1414
|
+
app_name: options.name
|
|
1355
1415
|
});
|
|
1356
1416
|
const result = response.message || response;
|
|
1357
|
-
|
|
1358
|
-
console.log(chalk.green('\n✅
|
|
1417
|
+
|
|
1418
|
+
console.log(chalk.green('\n✅ App display name updated!\n'));
|
|
1359
1419
|
console.log(chalk.cyan(` App: ${options.community}/${options.app}`));
|
|
1360
|
-
console.log(chalk.gray(`
|
|
1420
|
+
console.log(chalk.gray(` Name: ${options.name}\n`));
|
|
1361
1421
|
} catch (error) {
|
|
1362
1422
|
console.error(chalk.red(error.message));
|
|
1363
1423
|
process.exit(1);
|
|
@@ -1365,69 +1425,35 @@ appCommand
|
|
|
1365
1425
|
});
|
|
1366
1426
|
|
|
1367
1427
|
appCommand
|
|
1368
|
-
.command('
|
|
1369
|
-
.description('
|
|
1370
|
-
.
|
|
1428
|
+
.command('set-api-url')
|
|
1429
|
+
.description('Set API base URL for app\'s own backend')
|
|
1430
|
+
.requiredOption('-c, --community <id>', 'Community ID')
|
|
1431
|
+
.requiredOption('-a, --app <id>', 'App ID')
|
|
1432
|
+
.requiredOption('-u, --url <url>', 'API base URL (e.g., https://api.example.com)')
|
|
1371
1433
|
.action(async (options) => {
|
|
1372
1434
|
try {
|
|
1373
1435
|
const apiClient = new DeSciXApiClient();
|
|
1374
1436
|
await requireAuth(apiClient);
|
|
1375
1437
|
|
|
1376
|
-
//
|
|
1377
|
-
const manifestPath = path.resolve(options.manifest || './manifest.json');
|
|
1378
|
-
|
|
1379
|
-
console.log(chalk.cyan(`\n📦 Deploying microservice from ${manifestPath}\n`));
|
|
1380
|
-
|
|
1381
|
-
let manifest;
|
|
1438
|
+
// Validate URL format
|
|
1382
1439
|
try {
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
if (error.code === 'ENOENT') {
|
|
1387
|
-
console.error(chalk.red(`❌ manifest.json not found at ${manifestPath}`));
|
|
1388
|
-
console.error(chalk.gray('\nCreate a manifest.json with your service configuration.'));
|
|
1389
|
-
console.error(chalk.gray('See DeSciX_Core/descix-cli/templates/scaffolds/microservice/manifest.json for a template.\n'));
|
|
1390
|
-
process.exit(1);
|
|
1391
|
-
}
|
|
1392
|
-
throw new Error(`Failed to parse manifest.json: ${error.message}`);
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
// Validate required fields
|
|
1396
|
-
if (!manifest.service?.name) {
|
|
1397
|
-
throw new Error('manifest.service.name is required');
|
|
1398
|
-
}
|
|
1399
|
-
if (!manifest.service?.app_id) {
|
|
1400
|
-
throw new Error('manifest.service.app_id is required (the app you own)');
|
|
1401
|
-
}
|
|
1402
|
-
if (!manifest.service?.community_id) {
|
|
1403
|
-
throw new Error('manifest.service.community_id is required');
|
|
1404
|
-
}
|
|
1405
|
-
if (!manifest.service?.domain) {
|
|
1406
|
-
throw new Error('manifest.service.domain is required');
|
|
1440
|
+
new URL(options.url);
|
|
1441
|
+
} catch {
|
|
1442
|
+
throw new Error('Invalid URL format');
|
|
1407
1443
|
}
|
|
1408
1444
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
// Call register_service
|
|
1415
|
-
const response = await apiClient.invoke('register_service', { manifest });
|
|
1445
|
+
const response = await apiClient.invoke('update_app_metadata', {
|
|
1446
|
+
community_id: options.community,
|
|
1447
|
+
app_id: options.app,
|
|
1448
|
+
api_base_url: options.url
|
|
1449
|
+
});
|
|
1416
1450
|
const result = response.message || response;
|
|
1417
1451
|
|
|
1418
|
-
console.log(chalk.green('✅
|
|
1419
|
-
console.log(chalk.cyan(`
|
|
1420
|
-
console.log(chalk.gray(`
|
|
1421
|
-
|
|
1422
|
-
if (response.app_updated) {
|
|
1423
|
-
console.log(chalk.gray(` App api_base_url: ${response.api_base_url}`));
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
console.log(chalk.gray('\n The service manifest cache will refresh within 5 minutes.'));
|
|
1427
|
-
console.log(chalk.gray(' For immediate effect, an admin can run: descix microservice reload\n'));
|
|
1428
|
-
|
|
1452
|
+
console.log(chalk.green('\n✅ API base URL updated!\n'));
|
|
1453
|
+
console.log(chalk.cyan(` App: ${options.community}/${options.app}`));
|
|
1454
|
+
console.log(chalk.gray(` API URL: ${options.url}\n`));
|
|
1429
1455
|
} catch (error) {
|
|
1430
|
-
console.error(chalk.red(
|
|
1456
|
+
console.error(chalk.red(error.message));
|
|
1431
1457
|
process.exit(1);
|
|
1432
1458
|
}
|
|
1433
1459
|
});
|
|
@@ -1495,144 +1521,839 @@ appCommand
|
|
|
1495
1521
|
}
|
|
1496
1522
|
});
|
|
1497
1523
|
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
.
|
|
1502
|
-
.
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
.
|
|
1506
|
-
.description('Create a new knowledge base in an app')
|
|
1507
|
-
.requiredOption('-c, --community <id>', 'Community ID')
|
|
1508
|
-
.requiredOption('-a, --app <id>', 'App ID')
|
|
1509
|
-
.requiredOption('-k, --kb <id>', 'Knowledge Base ID')
|
|
1510
|
-
.option('--drive-folder <id>', 'Google Drive folder ID (optional, will create if not provided)')
|
|
1524
|
+
appCommand
|
|
1525
|
+
.command('delete')
|
|
1526
|
+
.description('Delete an app with full cascade cleanup (Pinecone, GCS, Firestore, Products)')
|
|
1527
|
+
.requiredOption('-a, --app <app_id>', 'App ID to delete')
|
|
1528
|
+
.option('-c, --community <community_id>', 'Community ID (auto-resolved from Products if omitted)')
|
|
1529
|
+
.option('--dry-run', 'Preview what would be deleted without executing')
|
|
1530
|
+
.option('--soft', 'Soft delete only (mark as hidden, no cascade)')
|
|
1531
|
+
.option('--yes', 'Skip confirmation prompt')
|
|
1511
1532
|
.action(async (options) => {
|
|
1512
1533
|
try {
|
|
1513
1534
|
const apiClient = new DeSciXApiClient();
|
|
1514
1535
|
await requireAuth(apiClient);
|
|
1515
|
-
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1536
|
+
|
|
1537
|
+
const appId = options.app;
|
|
1538
|
+
const hardDelete = !options.soft;
|
|
1539
|
+
const dryRun = !!options.dryRun;
|
|
1540
|
+
|
|
1541
|
+
// Resolve community_id if not provided (apiFront hydrates from Products)
|
|
1542
|
+
let communityId = options.community || null;
|
|
1543
|
+
|
|
1544
|
+
if (dryRun) {
|
|
1545
|
+
console.log(chalk.cyan(`\n--- DRY RUN: delete ${appId} ---\n`));
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const response = await apiClient.invoke('delete_app', {
|
|
1549
|
+
app_id: appId,
|
|
1550
|
+
community_id: communityId,
|
|
1551
|
+
hard_delete: hardDelete,
|
|
1552
|
+
dry_run: dryRun
|
|
1521
1553
|
});
|
|
1554
|
+
|
|
1522
1555
|
const result = response.message || response;
|
|
1523
|
-
|
|
1524
|
-
console.log(chalk.green('\n✅ Knowledge Base created successfully!\n'));
|
|
1525
|
-
console.log(chalk.cyan(` KB ID: ${options.kb}`));
|
|
1526
|
-
console.log(chalk.gray(` App: ${options.community}/${options.app}`));
|
|
1527
|
-
if (result.drive_folder_id) {
|
|
1528
|
-
console.log(chalk.gray(` Drive Folder: ${result.drive_folder_id}\n`));
|
|
1529
|
-
}
|
|
1530
|
-
} catch (error) {
|
|
1531
|
-
console.error(chalk.red(error.message));
|
|
1532
|
-
process.exit(1);
|
|
1533
|
-
}
|
|
1534
|
-
});
|
|
1535
1556
|
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1557
|
+
if (dryRun) {
|
|
1558
|
+
const m = result.deletion_manifest || result;
|
|
1559
|
+
console.log(chalk.bold('Deletion manifest:\n'));
|
|
1560
|
+
if (m.pinecone_only_orphan) {
|
|
1561
|
+
console.log(chalk.yellow(` ** Pinecone-only orphan: no Firestore/Products records found **\n`));
|
|
1562
|
+
}
|
|
1563
|
+
if (m.app_doc) console.log(chalk.gray(` Firestore App: ${m.app_doc}`));
|
|
1564
|
+
if (m.products_doc) console.log(chalk.gray(` Products Doc: ${m.products_doc}`));
|
|
1565
|
+
if (m.knowledgebases?.length) {
|
|
1566
|
+
console.log(chalk.gray(` Knowledge Bases: ${m.knowledgebases.length}`));
|
|
1567
|
+
for (const kb of m.knowledgebases) {
|
|
1568
|
+
console.log(chalk.gray(` - ${kb}`));
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
if (m.pinecone_note) {
|
|
1572
|
+
console.log(chalk.yellow(` Pinecone: ${m.pinecone_note}`));
|
|
1573
|
+
} else {
|
|
1574
|
+
console.log(chalk.gray(` Pinecone filter: app_id == ${appId} (${m.pinecone_kb_count} KBs)`));
|
|
1575
|
+
}
|
|
1576
|
+
console.log(chalk.gray(` GCS prefix: ${m.gcs_prefix}`));
|
|
1577
|
+
console.log(chalk.gray(` IPDocs: ${m.ip_docs_count}`));
|
|
1578
|
+
if (m.service_manifests?.length) {
|
|
1579
|
+
console.log(chalk.gray(` ServiceManifests: ${m.service_manifests.join(', ')}`));
|
|
1580
|
+
}
|
|
1581
|
+
console.log(chalk.yellow('\n No changes were made. Remove --dry-run to execute.\n'));
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// Confirmation prompt (unless --yes)
|
|
1586
|
+
if (!options.yes && hardDelete) {
|
|
1587
|
+
const readline = await import('readline');
|
|
1588
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1589
|
+
const answer = await new Promise(resolve => {
|
|
1590
|
+
rl.question(chalk.yellow(`\n Permanently delete ${appId}? This cannot be undone. [y/N] `), resolve);
|
|
1591
|
+
});
|
|
1592
|
+
rl.close();
|
|
1593
|
+
if (answer.toLowerCase() !== 'y') {
|
|
1594
|
+
console.log(chalk.gray('\n Aborted.\n'));
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Display results
|
|
1600
|
+
if (result.cleanup) {
|
|
1601
|
+
const c = result.cleanup;
|
|
1602
|
+
console.log(chalk.green(`\n App ${appId} deleted.\n`));
|
|
1603
|
+
console.log(chalk.gray(` Pinecone vectors: ${c.pinecone_deleted === -1 ? 'filter-deleted' : c.pinecone_deleted} (KBs: ${c.kbs_deleted?.join(', ') || 'none'})`));
|
|
1604
|
+
console.log(chalk.gray(` GCS files: ${c.gcs_deleted}`));
|
|
1605
|
+
console.log(chalk.gray(` Firestore docs: ${c.firestore_deleted} (IPDocs)`));
|
|
1606
|
+
console.log(chalk.gray(` Products deleted: ${c.products_deleted ? 'yes' : 'no'}`));
|
|
1607
|
+
if (c.service_manifests_deleted?.length) {
|
|
1608
|
+
console.log(chalk.gray(` ServiceManifests: ${c.service_manifests_deleted.join(', ')}`));
|
|
1609
|
+
}
|
|
1610
|
+
console.log();
|
|
1611
|
+
} else {
|
|
1612
|
+
console.log(chalk.green(`\n ${result.message || `App ${appId} deleted.`}\n`));
|
|
1613
|
+
}
|
|
1614
|
+
} catch (error) {
|
|
1615
|
+
console.error(chalk.red(`\n Error: ${error.message}\n`));
|
|
1616
|
+
process.exit(1);
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
appCommand
|
|
1621
|
+
.command('sync-assets')
|
|
1622
|
+
// Canonical asset-sync surface. The legacy Drive-mode `sync assets` DUPLICATE was removed in
|
|
1623
|
+
// WS-MCP-SURFACE-SPLIT Step 3 §5.5; this is now the sole path.
|
|
1624
|
+
.description('Sync local assets (system_instructions.md, app_description.md, icon.png) to platform.')
|
|
1625
|
+
.requiredOption('-a, --app <app_id>', 'App ID')
|
|
1626
|
+
.option('-k, --kb <name>', 'Target KB for system_instructions', 'General')
|
|
1627
|
+
.option('-p, --path <dir>', 'App directory (default: auto-detected)')
|
|
1546
1628
|
.action(async (options) => {
|
|
1547
1629
|
try {
|
|
1548
|
-
|
|
1630
|
+
const apiClient = new DeSciXApiClient();
|
|
1631
|
+
await requireAuth(apiClient);
|
|
1632
|
+
const fs = await import('fs');
|
|
1633
|
+
const path = await import('path');
|
|
1634
|
+
|
|
1635
|
+
// Resolve app path via WorkspaceConfig (v2.1 — localPath from workspace.json)
|
|
1636
|
+
let appPath = options.path;
|
|
1637
|
+
if (!appPath) {
|
|
1638
|
+
try {
|
|
1639
|
+
const wsConfig = await WorkspaceConfig.load();
|
|
1640
|
+
const appConfig = wsConfig.getAppByAppId(options.app);
|
|
1641
|
+
if (appConfig?.absolutePath) {
|
|
1642
|
+
appPath = appConfig.absolutePath;
|
|
1643
|
+
}
|
|
1644
|
+
} catch { /* fall through */ }
|
|
1645
|
+
}
|
|
1646
|
+
if (!appPath) {
|
|
1647
|
+
console.error(chalk.red(`App '${options.app}' not found in workspace.json. Use -p to specify path.`));
|
|
1648
|
+
process.exit(1);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
const assetsDir = path.default.join(appPath, 'assets');
|
|
1652
|
+
const assets = {};
|
|
1653
|
+
let found = 0;
|
|
1654
|
+
|
|
1655
|
+
// system_instructions.md
|
|
1656
|
+
const siPath = path.default.join(assetsDir, 'system_instructions.md');
|
|
1657
|
+
if (fs.default.existsSync(siPath)) {
|
|
1658
|
+
assets.system_instructions = fs.default.readFileSync(siPath, 'utf8');
|
|
1659
|
+
found++;
|
|
1660
|
+
console.log(chalk.gray(` Found: ${siPath}`));
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// app_description.md
|
|
1664
|
+
const descPath = path.default.join(assetsDir, 'app_description.md');
|
|
1665
|
+
if (fs.default.existsSync(descPath)) {
|
|
1666
|
+
assets.app_description = fs.default.readFileSync(descPath, 'utf8');
|
|
1667
|
+
found++;
|
|
1668
|
+
console.log(chalk.gray(` Found: ${descPath}`));
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// icon.png
|
|
1672
|
+
const iconPath = path.default.join(assetsDir, 'icon.png');
|
|
1673
|
+
if (fs.default.existsSync(iconPath)) {
|
|
1674
|
+
assets.icon_base64 = fs.default.readFileSync(iconPath).toString('base64');
|
|
1675
|
+
found++;
|
|
1676
|
+
console.log(chalk.gray(` Found: ${iconPath}`));
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
if (found === 0) {
|
|
1680
|
+
console.log(chalk.yellow(`\nNo assets found in ${assetsDir}`));
|
|
1681
|
+
console.log(chalk.gray('Expected: system_instructions.md, app_description.md, icon.png\n'));
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
console.log(chalk.gray(`\nSyncing ${found} asset(s) for ${options.app}...`));
|
|
1686
|
+
|
|
1687
|
+
const response = await apiClient.invoke('sync_app_assets', {
|
|
1688
|
+
app_id: options.app,
|
|
1689
|
+
kb_name: options.kb,
|
|
1690
|
+
assets
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
const result = response.message || response;
|
|
1694
|
+
if (result.results) {
|
|
1695
|
+
for (const [key, val] of Object.entries(result.results)) {
|
|
1696
|
+
if (val.updated) {
|
|
1697
|
+
console.log(chalk.green(` ${key}: synced${val.kb ? ` (KB: ${val.kb})` : ''}`));
|
|
1698
|
+
} else {
|
|
1699
|
+
console.log(chalk.red(` ${key}: failed — ${val.error || 'unknown'}`));
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
console.log(chalk.green('\nAsset sync complete.\n'));
|
|
1704
|
+
} catch (error) {
|
|
1705
|
+
console.error(chalk.red(error.message));
|
|
1706
|
+
process.exit(1);
|
|
1707
|
+
}
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
appCommand
|
|
1711
|
+
.command('set-localpath')
|
|
1712
|
+
.description('Update the local directory path for a mapped app')
|
|
1713
|
+
.requiredOption('-a, --app <app_id>', 'App ID')
|
|
1714
|
+
.requiredOption('-p, --path <dir>', 'New local directory path')
|
|
1715
|
+
.action(async (options) => {
|
|
1716
|
+
try {
|
|
1717
|
+
const appId = options.app;
|
|
1718
|
+
const newPath = options.path;
|
|
1719
|
+
|
|
1720
|
+
const workspaceConfig = await WorkspaceConfig.load();
|
|
1721
|
+
const appConfig = workspaceConfig.getAppByAppId(appId);
|
|
1722
|
+
if (!appConfig) {
|
|
1723
|
+
throw new Error(`App '${appId}' is not mapped. Run 'descix app init -a ${appId}' first.`);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// Hard-fail if path doesn't exist or is not a directory
|
|
1727
|
+
let stat;
|
|
1549
1728
|
try {
|
|
1550
|
-
|
|
1551
|
-
await apiClient.loadCredentials();
|
|
1729
|
+
stat = await fs.stat(newPath);
|
|
1552
1730
|
} catch {
|
|
1553
|
-
|
|
1731
|
+
throw new Error(`Path does not exist: ${newPath}`);
|
|
1554
1732
|
}
|
|
1555
|
-
|
|
1556
|
-
|
|
1733
|
+
if (!stat.isDirectory()) {
|
|
1734
|
+
throw new Error(`Path is not a directory: ${newPath}`);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// Update the localPath in workspace.json
|
|
1738
|
+
const wsRoot = workspaceConfig.workspaceRoot || process.cwd();
|
|
1739
|
+
const oldPath = appConfig.localPath;
|
|
1740
|
+
appConfig.localPath = newPath;
|
|
1741
|
+
|
|
1742
|
+
// Update env.products entry
|
|
1743
|
+
const products = workspaceConfig.env?.products || [];
|
|
1744
|
+
for (const product of products) {
|
|
1745
|
+
if (product.appId === appId || product.app_id === appId) {
|
|
1746
|
+
product.localPath = newPath;
|
|
1747
|
+
break;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
await workspaceConfig.save(wsRoot);
|
|
1751
|
+
|
|
1752
|
+
console.log(chalk.green(`\n✓ ${appId} local path updated`));
|
|
1753
|
+
console.log(chalk.gray(` ${oldPath} → ${newPath}\n`));
|
|
1557
1754
|
} catch (error) {
|
|
1558
|
-
console.error(chalk.red(
|
|
1755
|
+
console.error(chalk.red(error.message));
|
|
1756
|
+
process.exit(1);
|
|
1757
|
+
}
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
appCommand
|
|
1761
|
+
.command('unmap')
|
|
1762
|
+
.description('Remove an app from the local workspace mapping (does not delete Firestore/Pinecone data)')
|
|
1763
|
+
.requiredOption('-a, --app <app_id>', 'App ID')
|
|
1764
|
+
.action(async (options) => {
|
|
1765
|
+
try {
|
|
1766
|
+
const appId = options.app;
|
|
1767
|
+
|
|
1768
|
+
const workspaceConfig = await WorkspaceConfig.load();
|
|
1769
|
+
const appConfig = workspaceConfig.getAppByAppId(appId);
|
|
1770
|
+
if (!appConfig) {
|
|
1771
|
+
throw new Error(`App '${appId}' is not mapped.`);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// Remove from env.products
|
|
1775
|
+
const products = workspaceConfig.env?.products || [];
|
|
1776
|
+
const idx = products.findIndex(p => p.appId === appId || p.app_id === appId);
|
|
1777
|
+
if (idx === -1) {
|
|
1778
|
+
throw new Error(`App '${appId}' is not found in env.products.`);
|
|
1779
|
+
}
|
|
1780
|
+
products.splice(idx, 1);
|
|
1781
|
+
|
|
1782
|
+
const wsRoot = workspaceConfig.workspaceRoot || process.cwd();
|
|
1783
|
+
await workspaceConfig.save(wsRoot);
|
|
1784
|
+
|
|
1785
|
+
console.log(chalk.green(`\n✓ ${appId} removed from workspace mapping`));
|
|
1786
|
+
console.log(chalk.gray(` Pinecone vectors, Firestore docs, and Drive data are not affected.\n`));
|
|
1787
|
+
} catch (error) {
|
|
1788
|
+
console.error(chalk.red(error.message));
|
|
1559
1789
|
process.exit(1);
|
|
1560
1790
|
}
|
|
1561
1791
|
});
|
|
1562
1792
|
|
|
1793
|
+
// ============ App: set-port (WS-CLI-MESH-ROUTING-GAP) ============
|
|
1794
|
+
//
|
|
1795
|
+
// Canonical write path for an app's microservice.port in workspace.json.
|
|
1796
|
+
// `descix microservice init` READS env.products[<app>].microservice.port and hard-fails
|
|
1797
|
+
// if it is missing; this command is how that port is set, without hand-editing workspace.json.
|
|
1798
|
+
// Backed by WorkspaceConfig.setMicroservicePort (parallel to setSitePort).
|
|
1799
|
+
appCommand
|
|
1800
|
+
.command('set-port')
|
|
1801
|
+
.description('Set the microservice port for a mapped app (writes env.products[<app>].microservice.port). Pass "n" to remove.')
|
|
1802
|
+
.requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json)')
|
|
1803
|
+
.requiredOption('-p, --port <port>', 'Microservice port number (1-65535), or "n" to remove')
|
|
1804
|
+
.action(async (options) => {
|
|
1805
|
+
try {
|
|
1806
|
+
const appId = options.app;
|
|
1807
|
+
const workspaceConfig = await WorkspaceConfig.load();
|
|
1808
|
+
|
|
1809
|
+
// Disable case — setMicroservicePort(appId, null) removes microservice.port / cleans microservice.{}
|
|
1810
|
+
if (options.port === 'n' || options.port === 'N') {
|
|
1811
|
+
await workspaceConfig.setMicroservicePort(appId, null);
|
|
1812
|
+
console.log(chalk.green(`\n✓ Microservice port removed for ${appId}\n`));
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const portNum = parseInt(options.port, 10);
|
|
1817
|
+
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
1818
|
+
console.error(chalk.red('\n❌ Invalid port number. Provide an integer 1-65535, or "n" to remove.\n'));
|
|
1819
|
+
process.exit(1);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// setMicroservicePort mutates the live env entry and saves — and hard-fails (canonical
|
|
1823
|
+
// "not mapped in workspace.json" error) for an unmapped app, surfaced in the catch below.
|
|
1824
|
+
await workspaceConfig.setMicroservicePort(appId, portNum);
|
|
1825
|
+
|
|
1826
|
+
console.log(chalk.green(`\n✓ Microservice port set for ${appId}`));
|
|
1827
|
+
console.log(chalk.cyan(` Port: ${portNum}`));
|
|
1828
|
+
console.log(chalk.gray(` Written to env.products[${appId}].microservice.port in workspace.json.`));
|
|
1829
|
+
console.log(chalk.gray(` The gateway routes the microservice on this port; \`descix microservice init -a ${appId}\` can now scaffold it.\n`));
|
|
1830
|
+
} catch (error) {
|
|
1831
|
+
console.error(chalk.red(error.message));
|
|
1832
|
+
process.exit(1);
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
// ============ App: set-site (WS-SSGPOD — site.static workspace gap) ============
|
|
1837
|
+
//
|
|
1838
|
+
// Canonical write path for an app's site config in workspace.json — specifically site.static,
|
|
1839
|
+
// the relative path the dev gateway's staticSitePlugin serves at /p/{appId}/ (resolved against
|
|
1840
|
+
// the app's localPath; "." means the localPath itself). Optionally sets site.port for
|
|
1841
|
+
// dev-server sites. Parallel to `descix app set-port` (microservice.port); closes the
|
|
1842
|
+
// site.static gap so workspace.json never needs hand-editing (CEO-D-2026-06-02-SSGPOD-SITE-PREPROD).
|
|
1843
|
+
// Backed by WorkspaceConfig.setStaticSite (parallel to setSitePort/setMicroservicePort).
|
|
1844
|
+
//
|
|
1845
|
+
// NOTE: This is NOT `set-codesite` — that writes the Firestore ip_site_gcs_path_url (a prod
|
|
1846
|
+
// concern). set-site writes ONLY the local workspace.json site.{} slot.
|
|
1847
|
+
appCommand
|
|
1848
|
+
.command('set-site')
|
|
1849
|
+
.description("Set a mapped app static-site config (writes env.products[<app>].site.static, the relative dir served at /p/<app>/). Optionally --port; --unset clears site.{}.")
|
|
1850
|
+
.requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json)')
|
|
1851
|
+
.option('--static <path>', 'Relative static-site path under the app localPath (e.g. "site"; "." = the localPath itself)')
|
|
1852
|
+
.option('--port <port>', 'Site dev-server port (1-65535) for framework dev sites; mutually optional with --static')
|
|
1853
|
+
.option('--unset', 'Remove site.static (and site.port) for the app, clearing the site.{} slot')
|
|
1854
|
+
.action(async (options) => {
|
|
1855
|
+
try {
|
|
1856
|
+
const appId = options.app;
|
|
1857
|
+
const workspaceConfig = await WorkspaceConfig.load();
|
|
1858
|
+
|
|
1859
|
+
// --unset: remove both site.static and site.port (setStaticSite cleans up empty site.{})
|
|
1860
|
+
if (options.unset) {
|
|
1861
|
+
await workspaceConfig.setStaticSite(appId, { static: null, port: null });
|
|
1862
|
+
console.log(chalk.green(`\n✓ site config cleared for ${appId}\n`));
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// Require at least one field to set — do not silently no-op.
|
|
1867
|
+
if (options.static === undefined && options.port === undefined) {
|
|
1868
|
+
console.error(chalk.red('\n❌ Nothing to set. Provide --static <path> and/or --port <n>, or --unset to clear.\n'));
|
|
1869
|
+
process.exit(1);
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
const fields = {};
|
|
1873
|
+
|
|
1874
|
+
if (options.static !== undefined) {
|
|
1875
|
+
if (typeof options.static !== 'string' || options.static.trim() === '') {
|
|
1876
|
+
console.error(chalk.red('\n❌ --static requires a non-empty relative path (e.g. "site" or ".").\n'));
|
|
1877
|
+
process.exit(1);
|
|
1878
|
+
}
|
|
1879
|
+
fields.static = options.static;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
if (options.port !== undefined) {
|
|
1883
|
+
const portNum = parseInt(options.port, 10);
|
|
1884
|
+
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
1885
|
+
console.error(chalk.red('\n❌ Invalid port number. Provide an integer 1-65535.\n'));
|
|
1886
|
+
process.exit(1);
|
|
1887
|
+
}
|
|
1888
|
+
fields.port = portNum;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// setStaticSite mutates the live env entry and saves — and hard-fails (canonical
|
|
1892
|
+
// "not mapped in workspace.json" error) for an unmapped app, surfaced in the catch below.
|
|
1893
|
+
await workspaceConfig.setStaticSite(appId, fields);
|
|
1894
|
+
|
|
1895
|
+
console.log(chalk.green(`\n✓ site config set for ${appId}`));
|
|
1896
|
+
if (fields.static !== undefined) console.log(chalk.cyan(` site.static: ${fields.static}`));
|
|
1897
|
+
if (fields.port !== undefined) console.log(chalk.cyan(` site.port: ${fields.port}`));
|
|
1898
|
+
console.log(chalk.gray(` Written to env.products[${appId}].site in workspace.json.`));
|
|
1899
|
+
console.log(chalk.gray(` The gateway serves site.static at /p/${appId}/ (staticSitePlugin); \`descix serve\` picks it up.\n`));
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
console.error(chalk.red(error.message));
|
|
1902
|
+
process.exit(1);
|
|
1903
|
+
}
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
// ============ App: open (WS-SSGPOD — dev URL resolver) ============
|
|
1908
|
+
//
|
|
1909
|
+
// Resolves an app's LOCAL GATEWAY URL (the URL served by `descix serve`, which mirrors
|
|
1910
|
+
// the production LB) and prints it; with --open, launches the default browser.
|
|
1911
|
+
// Reuses the SDK resolver resolveAppGatewayUrl() — the same workspace.json read path
|
|
1912
|
+
// behind buildWorkspaceProducts() that the PWA bakes into __WORKSPACE_PRODUCTS__ — so
|
|
1913
|
+
// the URL matches what the app store routes to:
|
|
1914
|
+
// - env.platform → https://localhost:{gatewayPort}/
|
|
1915
|
+
// - product (static) → https://localhost:{gatewayPort}/p/{appId}
|
|
1916
|
+
// - product (dev-server)→ https://localhost:{gatewayPort}/p/{appId}
|
|
1917
|
+
// Hard-fails clearly if the app is unmapped or has no site config.
|
|
1918
|
+
appCommand
|
|
1919
|
+
.command('open')
|
|
1920
|
+
.description('Resolve (and optionally open) an app\'s local gateway dev URL. Hard-fails if the app has no site config.')
|
|
1921
|
+
.requiredOption('-a, --app <app_id>', 'App ID (must be mapped in workspace.json with a site)')
|
|
1922
|
+
.option('--open', 'Open the resolved URL in the default browser')
|
|
1923
|
+
.action(async (options) => {
|
|
1924
|
+
try {
|
|
1925
|
+
const appId = options.app;
|
|
1926
|
+
const { resolveAppGatewayUrl } = await import('@descix/app-sdk/dev');
|
|
1927
|
+
const workspaceConfig = await WorkspaceConfig.load();
|
|
1928
|
+
const workspaceRoot = workspaceConfig.workspaceRoot || process.cwd();
|
|
1929
|
+
|
|
1930
|
+
// resolveAppGatewayUrl hard-fails (no site / unmapped / no workspace) — surfaced in catch.
|
|
1931
|
+
const resolved = resolveAppGatewayUrl(workspaceRoot, appId);
|
|
1932
|
+
|
|
1933
|
+
console.log(chalk.green(`\n${appId} → ${resolved.url}`));
|
|
1934
|
+
console.log(chalk.gray(` kind: ${resolved.kind}`));
|
|
1935
|
+
console.log(chalk.gray(` via: ${resolved.via}`));
|
|
1936
|
+
console.log(chalk.gray(` (start the gateway with \`descix serve\` on port ${resolved.gatewayPort} if it is not already running)\n`));
|
|
1937
|
+
|
|
1938
|
+
if (options.open) {
|
|
1939
|
+
// Use the platform-native opener — no extra npm dependency, no hardcoded fallback.
|
|
1940
|
+
const { spawn } = await import('node:child_process');
|
|
1941
|
+
const platform = process.platform;
|
|
1942
|
+
const opener = platform === 'darwin' ? 'open'
|
|
1943
|
+
: platform === 'win32' ? 'cmd'
|
|
1944
|
+
: 'xdg-open';
|
|
1945
|
+
const args = platform === 'win32' ? ['/c', 'start', '', resolved.url] : [resolved.url];
|
|
1946
|
+
const child = spawn(opener, args, { stdio: 'ignore', detached: true });
|
|
1947
|
+
child.on('error', (err) => {
|
|
1948
|
+
console.error(chalk.yellow(` Could not launch browser (${err.message}). Open the URL above manually.`));
|
|
1949
|
+
});
|
|
1950
|
+
child.unref();
|
|
1951
|
+
}
|
|
1952
|
+
} catch (error) {
|
|
1953
|
+
console.error(chalk.red(error.message));
|
|
1954
|
+
process.exit(1);
|
|
1955
|
+
}
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
// ============ App: set-default-model (WS-CONFIG-BOOTSTRAP-FIX item #10) ============
|
|
1960
|
+
//
|
|
1961
|
+
// Updates Community/{c}/Apps/{a}.default_app_model. Per CEO inheritance chain:
|
|
1962
|
+
// options.model > kb.kb_model_override > app.default_app_model > levelConfig.model > DEFAULT_AI_MODEL
|
|
1963
|
+
//
|
|
1964
|
+
// --clear deletes the field via FieldValue.delete() (NOT null) so the resolver falls through
|
|
1965
|
+
// cleanly to per-level platform defaults.
|
|
1966
|
+
|
|
1967
|
+
appCommand
|
|
1968
|
+
.command('set-default-model')
|
|
1969
|
+
.description('Set or clear App.default_app_model (model used when no KB override is set)')
|
|
1970
|
+
.requiredOption('-a, --app <app_id>', 'App ID (e.g. unk-cos)')
|
|
1971
|
+
.option('-m, --model <model_name>', 'Gemini model name (e.g. gemini-3.1-flash-lite). Mutually exclusive with --clear.')
|
|
1972
|
+
.option('--clear', 'Delete App.default_app_model via FieldValue.delete() (resets to platform-default inheritance)')
|
|
1973
|
+
.addHelpText('after', `
|
|
1974
|
+
NOTE: Pinning a "Pro" model (e.g., gemini-2.5-pro, gemini-3.1-pro-preview) as
|
|
1975
|
+
an app or KB override will fail at L1 — these models require thinking mode and
|
|
1976
|
+
reject L1's thinkingBudget=0. If you intend an L1-compatible override, use a
|
|
1977
|
+
"Flash" model (gemini-2.5-flash, gemini-3.1-flash-lite, gemini-3-flash-preview).
|
|
1978
|
+
`)
|
|
1979
|
+
.action(async (options) => {
|
|
1980
|
+
try {
|
|
1981
|
+
// Forward the parent --env flag so the audit log records the target environment.
|
|
1982
|
+
const parentEnv = program.opts().env || null;
|
|
1983
|
+
await modelConfigCommands.runAppSetDefaultModel({ ...options, env: parentEnv });
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
console.error(chalk.red(`\n\u274c ${error.message}\n`));
|
|
1986
|
+
process.exit(1);
|
|
1987
|
+
}
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
// ============ Knowledge Base Commands ============
|
|
1991
|
+
|
|
1992
|
+
const kbCommand = program
|
|
1993
|
+
.command('kb')
|
|
1994
|
+
.description('Knowledge base operations');
|
|
1995
|
+
|
|
1563
1996
|
kbCommand
|
|
1564
|
-
.command('
|
|
1565
|
-
.description('
|
|
1566
|
-
.
|
|
1567
|
-
.option('
|
|
1568
|
-
.
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1997
|
+
.command('list')
|
|
1998
|
+
.description('List knowledge bases for an app')
|
|
1999
|
+
.requiredOption('-a, --app <app_id>', 'App ID')
|
|
2000
|
+
.option('--cached', 'Show the fast cached vector counter instead of the TRUE live Pinecone count. The cached counter is written ONLY by the CLI/PWA sync path, so it reports 0 for KBs ingested by any other route even when their vectors retrieve. Default is the live count.')
|
|
2001
|
+
.addHelpText('after', `
|
|
2002
|
+
By default the Vectors column is the TRUE live Pinecone count (server enumerates each
|
|
2003
|
+
KB's vectors by id-prefix). The Source column shows how each count was obtained:
|
|
2004
|
+
live = counted live from Pinecone (authoritative)
|
|
2005
|
+
cached = the fast cached counter (--cached; may under-report; verify with a live run)
|
|
2006
|
+
unknown = live count requested but Pinecone enumeration failed — shown as '?', NOT a fake 0
|
|
2007
|
+
`)
|
|
1572
2008
|
.action(async (options) => {
|
|
1573
2009
|
try {
|
|
1574
|
-
|
|
2010
|
+
const apiClient = new DeSciXApiClient();
|
|
2011
|
+
await requireAuth(apiClient);
|
|
2012
|
+
|
|
2013
|
+
// Default to the TRUE live Pinecone count. --cached opts into the fast (possibly
|
|
2014
|
+
// stale) cached counter that only the sync path writes.
|
|
2015
|
+
const live = !options.cached;
|
|
2016
|
+
const response = await apiClient.invoke('list_knowledge_bases', {
|
|
2017
|
+
app_id: options.app,
|
|
2018
|
+
live
|
|
2019
|
+
});
|
|
2020
|
+
const result = response.message || response;
|
|
2021
|
+
const kbs = result.knowledge_bases || [];
|
|
2022
|
+
|
|
2023
|
+
if (kbs.length === 0) {
|
|
2024
|
+
console.log(chalk.yellow(`\nNo knowledge bases found for app ${options.app}\n`));
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// Render helpers live in ../lib/commands/kb-list-render.js (unit-tested there).
|
|
2029
|
+
const vectorCell = (kb) => kbVectorCell(kb);
|
|
2030
|
+
const sourceCell = (kb) => kbCountSource(kb, live);
|
|
2031
|
+
|
|
2032
|
+
// Table output
|
|
2033
|
+
const nameW = Math.max(7, ...kbs.map(k => (k.name || '').length));
|
|
2034
|
+
const modelW = Math.max(5, ...kbs.map(k => (k.model || '').length));
|
|
2035
|
+
const vecW = Math.max(7, ...kbs.map(k => vectorCell(k).length));
|
|
2036
|
+
|
|
2037
|
+
console.log('\n' + chalk.bold(
|
|
2038
|
+
'KB Name'.padEnd(nameW + 2) +
|
|
2039
|
+
'Model'.padEnd(modelW + 2) +
|
|
2040
|
+
'Instructions'.padEnd(14) +
|
|
2041
|
+
'Vectors'.padEnd(vecW + 2) +
|
|
2042
|
+
'Source'.padEnd(9) +
|
|
2043
|
+
'Last Sync'
|
|
2044
|
+
));
|
|
2045
|
+
console.log('-'.repeat(nameW + modelW + 49));
|
|
2046
|
+
|
|
2047
|
+
for (const kb of kbs) {
|
|
2048
|
+
const syncDate = kb.rag_last_sync
|
|
2049
|
+
? new Date(kb.rag_last_sync._seconds ? kb.rag_last_sync._seconds * 1000 : kb.rag_last_sync).toISOString().split('T')[0]
|
|
2050
|
+
: '-';
|
|
2051
|
+
const src = sourceCell(kb);
|
|
2052
|
+
const srcColor = src === 'live' ? chalk.green : src === 'unknown' ? chalk.yellow : chalk.gray;
|
|
2053
|
+
console.log(
|
|
2054
|
+
chalk.cyan((kb.name || '').padEnd(nameW + 2)) +
|
|
2055
|
+
chalk.gray((kb.model || '').padEnd(modelW + 2)) +
|
|
2056
|
+
(kb.system_instructions === 'present' ? chalk.green('present') : chalk.gray('empty')).padEnd(14 + 10) +
|
|
2057
|
+
vectorCell(kb).padEnd(vecW + 2) +
|
|
2058
|
+
srcColor(src.padEnd(9)) +
|
|
2059
|
+
syncDate
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
console.log();
|
|
1575
2063
|
} catch (error) {
|
|
1576
|
-
console.error(chalk.red(
|
|
2064
|
+
console.error(chalk.red(error.message));
|
|
1577
2065
|
process.exit(1);
|
|
1578
2066
|
}
|
|
1579
2067
|
});
|
|
1580
2068
|
|
|
1581
2069
|
kbCommand
|
|
1582
|
-
.command('
|
|
1583
|
-
.description('
|
|
1584
|
-
.
|
|
1585
|
-
.
|
|
1586
|
-
.
|
|
1587
|
-
.option('-v, --verbose', 'Show verbose output')
|
|
2070
|
+
.command('create')
|
|
2071
|
+
.description('Create a new knowledge base in an app')
|
|
2072
|
+
.requiredOption('-c, --community <id>', 'Community ID')
|
|
2073
|
+
.requiredOption('-a, --app <id>', 'App ID')
|
|
2074
|
+
.requiredOption('-k, --kb <id>', 'Knowledge Base ID')
|
|
1588
2075
|
.action(async (options) => {
|
|
1589
2076
|
try {
|
|
1590
2077
|
const apiClient = new DeSciXApiClient();
|
|
1591
2078
|
await requireAuth(apiClient);
|
|
1592
2079
|
|
|
1593
|
-
await
|
|
2080
|
+
const response = await apiClient.invoke('create_skeleton_kb', {
|
|
2081
|
+
community_id: options.community,
|
|
2082
|
+
app_id: options.app,
|
|
2083
|
+
kb_name: options.kb
|
|
2084
|
+
});
|
|
2085
|
+
const result = response.message || response;
|
|
2086
|
+
|
|
2087
|
+
console.log(chalk.green('\n✅ Knowledge Base created successfully!\n'));
|
|
2088
|
+
console.log(chalk.cyan(` KB ID: ${options.kb}`));
|
|
2089
|
+
console.log(chalk.gray(` App: ${options.community}/${options.app}`));
|
|
1594
2090
|
} catch (error) {
|
|
1595
|
-
console.error(chalk.red(
|
|
2091
|
+
console.error(chalk.red(error.message));
|
|
1596
2092
|
process.exit(1);
|
|
1597
2093
|
}
|
|
1598
2094
|
});
|
|
1599
2095
|
|
|
1600
2096
|
kbCommand
|
|
1601
|
-
.command('
|
|
1602
|
-
.description('
|
|
1603
|
-
.
|
|
1604
|
-
.
|
|
1605
|
-
.option('
|
|
1606
|
-
.
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
.option('--skip-staging', 'Skip staging push step')
|
|
1611
|
-
.option('--merge-mode <mode>', 'Pull merge mode: merge|overwrite|force-overwrite (default: merge)')
|
|
1612
|
-
.option('--dry-run', 'Show what would happen without making changes')
|
|
2097
|
+
.command('delete')
|
|
2098
|
+
.description('Delete a knowledge base registry doc (refuses a non-empty KB unless --force)')
|
|
2099
|
+
.requiredOption('-a, --app <app_id>', 'App ID')
|
|
2100
|
+
.requiredOption('-k, --kb <kb_name>', 'KB name')
|
|
2101
|
+
.option('--force', 'Purge the KB\'s live vectors and delete it even if non-empty. An empty KB deletes without this flag.')
|
|
2102
|
+
.addHelpText('after', `
|
|
2103
|
+
Fail-safe: the server reads the TRUE live Pinecone count. A KB with live vectors (or an
|
|
2104
|
+
undetermined count) is REFUSED unless --force. With --force the vectors are purged BEFORE
|
|
2105
|
+
the registry doc is removed, so a removed doc never strands orphan vectors.`)
|
|
1613
2106
|
.action(async (options) => {
|
|
1614
2107
|
try {
|
|
1615
2108
|
const apiClient = new DeSciXApiClient();
|
|
1616
2109
|
await requireAuth(apiClient);
|
|
1617
|
-
|
|
1618
|
-
await
|
|
2110
|
+
|
|
2111
|
+
const response = await apiClient.invoke('delete_knowledge_base', {
|
|
2112
|
+
app_id: options.app,
|
|
2113
|
+
kb_name: options.kb,
|
|
2114
|
+
force: !!options.force
|
|
2115
|
+
});
|
|
2116
|
+
const result = response.message || response;
|
|
2117
|
+
|
|
2118
|
+
console.log(chalk.green('\n✅ Knowledge base deleted.\n'));
|
|
2119
|
+
console.log(chalk.cyan(` KB: ${options.kb}`));
|
|
2120
|
+
console.log(chalk.gray(` App: ${result.community_id || '?'}/${options.app}`));
|
|
2121
|
+
if (result.vectors_purged) {
|
|
2122
|
+
console.log(chalk.gray(` Vectors purged: ${result.vectors_purged}`));
|
|
2123
|
+
}
|
|
2124
|
+
console.log();
|
|
1619
2125
|
} catch (error) {
|
|
1620
|
-
console.error(chalk.red(
|
|
2126
|
+
console.error(chalk.red(error.message));
|
|
1621
2127
|
process.exit(1);
|
|
1622
2128
|
}
|
|
1623
2129
|
});
|
|
1624
2130
|
|
|
2131
|
+
|
|
2132
|
+
// ============ KB: set-/clear-override-model (WS-CONFIG-BOOTSTRAP-FIX item #10) ============
|
|
2133
|
+
//
|
|
2134
|
+
// Updates Community/{c}/Apps/{a}/KnowledgeBases/{k}.kb_model_override.
|
|
2135
|
+
// Per CEO practice, KB overrides should be MINIMIZED — only use when a specific KB needs
|
|
2136
|
+
// a model different from App.default_app_model (e.g., a tuned model for one KB).
|
|
2137
|
+
//
|
|
2138
|
+
// clear-override-model deletes the field via FieldValue.delete() (NOT null) per tripwire #2.
|
|
2139
|
+
|
|
1625
2140
|
kbCommand
|
|
1626
|
-
.command('
|
|
1627
|
-
.description('
|
|
1628
|
-
.
|
|
1629
|
-
.
|
|
1630
|
-
.
|
|
2141
|
+
.command('set-override-model')
|
|
2142
|
+
.description('Set KB.kb_model_override (per-KB model selection)')
|
|
2143
|
+
.requiredOption('-a, --app <app_id>', 'App ID')
|
|
2144
|
+
.requiredOption('-k, --kb <kb_name>', 'KB name')
|
|
2145
|
+
.requiredOption('-m, --model <model_name>', 'Gemini model name (e.g. gemini-2.5-pro)')
|
|
2146
|
+
.addHelpText('after', `
|
|
2147
|
+
NOTE: Pinning a "Pro" model (e.g., gemini-2.5-pro, gemini-3.1-pro-preview) as
|
|
2148
|
+
an app or KB override will fail at L1 — these models require thinking mode and
|
|
2149
|
+
reject L1's thinkingBudget=0. If you intend an L1-compatible override, use a
|
|
2150
|
+
"Flash" model (gemini-2.5-flash, gemini-3.1-flash-lite, gemini-3-flash-preview).
|
|
2151
|
+
`)
|
|
2152
|
+
.action(async (options) => {
|
|
2153
|
+
try {
|
|
2154
|
+
const parentEnv = program.opts().env || null;
|
|
2155
|
+
await modelConfigCommands.runKbSetOverrideModel({ ...options, env: parentEnv });
|
|
2156
|
+
} catch (error) {
|
|
2157
|
+
console.error(chalk.red(`\n\u274c ${error.message}\n`));
|
|
2158
|
+
process.exit(1);
|
|
2159
|
+
}
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
kbCommand
|
|
2163
|
+
.command('clear-override-model')
|
|
2164
|
+
.description('Delete KB.kb_model_override via FieldValue.delete() (resets to App.default_app_model inheritance)')
|
|
2165
|
+
.requiredOption('-a, --app <app_id>', 'App ID')
|
|
2166
|
+
.requiredOption('-k, --kb <kb_name>', 'KB name')
|
|
2167
|
+
.action(async (options) => {
|
|
2168
|
+
try {
|
|
2169
|
+
const parentEnv = program.opts().env || null;
|
|
2170
|
+
await modelConfigCommands.runKbClearOverrideModel({ ...options, env: parentEnv });
|
|
2171
|
+
} catch (error) {
|
|
2172
|
+
console.error(chalk.red(`\n\u274c ${error.message}\n`));
|
|
2173
|
+
process.exit(1);
|
|
2174
|
+
}
|
|
2175
|
+
});
|
|
2176
|
+
|
|
2177
|
+
// `kb chunk` and `kb sync` (low-level Git-mode steps) are REMOVED. Their implementations
|
|
2178
|
+
// runKbChunk/runKbSync are deleted from lib/commands/kb.js. Both names exit non-zero naming
|
|
2179
|
+
// `descix kb corpus sync`.
|
|
2180
|
+
// EVERY retired kb-sync surface is registered here, by ITERATING the owner list -- no verb
|
|
2181
|
+
// name is typed in this file. Adding a surface to RETIRED_KB_SYNC_SURFACES registers it;
|
|
2182
|
+
// nothing can be registered without being in that list.
|
|
2183
|
+
registerAllRetiredKbSync({ program, kb: kbCommand }, chalk.red);
|
|
2184
|
+
|
|
2185
|
+
|
|
2186
|
+
// app records — APP DATA PLANE structured record store (CEO-D-2026-06-02-APP-DATA-PLANE)
|
|
2187
|
+
// Treat your app like a database table: put/query/get/delete structured records
|
|
2188
|
+
// with custom metadata. Records live in the app data plane (Firestore document
|
|
2189
|
+
// collections), NOT the Pinecone KB. `query` is a STRUCTURED, strongly-consistent
|
|
2190
|
+
// metadata-filtered scan (non-ANN) returning full records; for semantic ANN search
|
|
2191
|
+
// use `descix chat`.
|
|
2192
|
+
//
|
|
2193
|
+
// Canonical surface: `descix app records ...` (commands app_records_*).
|
|
2194
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2195
|
+
|
|
2196
|
+
/**
|
|
2197
|
+
* Attach the four records subcommands (put/query/get/delete) to a parent command.
|
|
2198
|
+
* @param {import('commander').Command} parent - the `records` parent command
|
|
2199
|
+
* @param {string} cmdPrefix - backend command prefix ('app_records')
|
|
2200
|
+
*/
|
|
2201
|
+
function attachRecordsSubcommands(parent, cmdPrefix) {
|
|
2202
|
+
parent
|
|
2203
|
+
.command('put')
|
|
2204
|
+
.description('Store/replace records in your app data store with custom metadata (your app as a database table). You supply file_id (+ optional chunk_idx); the id is built for you.')
|
|
2205
|
+
.requiredOption('-a, --app <app_id>', 'App ID')
|
|
2206
|
+
.requiredOption('-k, --kb <kb_id>', 'Record collection ID (acts like a table)')
|
|
2207
|
+
.requiredOption('-r, --records <json>', 'JSON array of records: [{ "file_id", "text"?, "chunk_idx"?, ...customMetadata }] — id is built from file_id (+ chunk_idx); pass a full "id" only for back-compat. Example: \'[{"file_id":"e1","text":"...","type":"episode","show":"x"}]\'')
|
|
2208
|
+
.option('--mode <mode>', 'upsert (default) | create — "create" is an atomic all-or-nothing conditional create: it fails if any key already exists and reports who holds it, instead of overwriting', 'upsert')
|
|
2209
|
+
.action(async (options) => {
|
|
2210
|
+
try {
|
|
2211
|
+
const apiClient = new DeSciXApiClient();
|
|
2212
|
+
await requireAuth(apiClient);
|
|
2213
|
+
let records;
|
|
2214
|
+
try { records = JSON.parse(options.records); } catch (e) { throw new Error(`--records must be valid JSON array: ${e.message}`); }
|
|
2215
|
+
if (!Array.isArray(records)) throw new Error('--records must be a JSON array');
|
|
2216
|
+
const response = await apiClient.invoke(`${cmdPrefix}_put`, { app_id: options.app, kb_id: options.kb, records, mode: options.mode });
|
|
2217
|
+
const result = response.message || response;
|
|
2218
|
+
// A refused conditional create is a DEFINITE answer, not a warning: report it as a
|
|
2219
|
+
// failure with the holder hint and a non-zero exit so scripts branch on it correctly.
|
|
2220
|
+
if (result.claimed === false) {
|
|
2221
|
+
console.error(chalk.red(`\n❌ claim refused — ${result.message || 'key already exists'}\n`));
|
|
2222
|
+
process.exit(1);
|
|
2223
|
+
}
|
|
2224
|
+
console.log(chalk.green(`\n✓ ${result.message || 'records put'}\n`));
|
|
2225
|
+
} catch (error) {
|
|
2226
|
+
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
2227
|
+
process.exit(1);
|
|
2228
|
+
}
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
parent
|
|
2232
|
+
.command('query')
|
|
2233
|
+
.description('STRUCTURED metadata-filtered scan (non-ANN) returning full records: "all records where type=episode AND show=X"')
|
|
2234
|
+
.requiredOption('-a, --app <app_id>', 'App ID')
|
|
2235
|
+
.requiredOption('-k, --kb <kb_id>', 'Record collection ID')
|
|
2236
|
+
.option('-f, --filter <json>', 'Metadata predicate JSON. Scalar fields: $eq/$ne/$in or a bare value; ARRAY fields: $contains. e.g. \'{"type":"episode","tags":{"$contains":"handoff"}}\'', '{}')
|
|
2237
|
+
.option('--fields <csv>', 'Comma-separated metadata projection (omit or "*" for all metadata)')
|
|
2238
|
+
.option('--limit <n>', 'Cap on returned records', (v) => parseInt(v, 10))
|
|
2239
|
+
.action(async (options) => {
|
|
2240
|
+
try {
|
|
2241
|
+
const apiClient = new DeSciXApiClient();
|
|
2242
|
+
await requireAuth(apiClient);
|
|
2243
|
+
let filter;
|
|
2244
|
+
try { filter = JSON.parse(options.filter); } catch (e) { throw new Error(`--filter must be valid JSON: ${e.message}`); }
|
|
2245
|
+
const fields = options.fields ? options.fields.split(',').map(s => s.trim()).filter(Boolean) : undefined;
|
|
2246
|
+
const params = { app_id: options.app, kb_id: options.kb, filter };
|
|
2247
|
+
if (fields) params.fields = fields;
|
|
2248
|
+
if (options.limit) params.limit = options.limit;
|
|
2249
|
+
const response = await apiClient.invoke(`${cmdPrefix}_query`, params);
|
|
2250
|
+
const result = response.message || response;
|
|
2251
|
+
console.log(chalk.gray(`\n${result.message || ''}`));
|
|
2252
|
+
console.log(JSON.stringify(result.records || [], null, 2));
|
|
2253
|
+
console.log();
|
|
2254
|
+
} catch (error) {
|
|
2255
|
+
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
2256
|
+
process.exit(1);
|
|
2257
|
+
}
|
|
2258
|
+
});
|
|
2259
|
+
|
|
2260
|
+
parent
|
|
2261
|
+
.command('get')
|
|
2262
|
+
.description('Fetch specific records by id (point lookup) with an arbitrary metadata projection')
|
|
2263
|
+
.requiredOption('-a, --app <app_id>', 'App ID')
|
|
2264
|
+
.requiredOption('-k, --kb <kb_id>', 'Record collection ID')
|
|
2265
|
+
.requiredOption('-i, --ids <csv>', 'Comma-separated record ids to fetch')
|
|
2266
|
+
.option('--fields <csv>', 'Comma-separated metadata projection (omit or "*" for all metadata)')
|
|
2267
|
+
.action(async (options) => {
|
|
2268
|
+
try {
|
|
2269
|
+
const apiClient = new DeSciXApiClient();
|
|
2270
|
+
await requireAuth(apiClient);
|
|
2271
|
+
const ids = options.ids.split(',').map(s => s.trim()).filter(Boolean);
|
|
2272
|
+
const fields = options.fields ? options.fields.split(',').map(s => s.trim()).filter(Boolean) : undefined;
|
|
2273
|
+
const params = { app_id: options.app, kb_id: options.kb, ids };
|
|
2274
|
+
if (fields) params.fields = fields;
|
|
2275
|
+
const response = await apiClient.invoke(`${cmdPrefix}_get`, params);
|
|
2276
|
+
const result = response.message || response;
|
|
2277
|
+
console.log(chalk.gray(`\n${result.message || ''}`));
|
|
2278
|
+
console.log(JSON.stringify(result.records || [], null, 2));
|
|
2279
|
+
console.log();
|
|
2280
|
+
} catch (error) {
|
|
2281
|
+
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
2282
|
+
process.exit(1);
|
|
2283
|
+
}
|
|
2284
|
+
});
|
|
2285
|
+
|
|
2286
|
+
parent
|
|
2287
|
+
.command('delete')
|
|
2288
|
+
.description('Delete records by id (or by file_id grouping key)')
|
|
2289
|
+
.requiredOption('-a, --app <app_id>', 'App ID')
|
|
2290
|
+
.requiredOption('-k, --kb <kb_id>', 'Record collection ID')
|
|
2291
|
+
.option('-i, --ids <csv>', 'Comma-separated record ids to delete')
|
|
2292
|
+
.option('--file-ids <csv>', 'Comma-separated file_id grouping keys to delete')
|
|
2293
|
+
.action(async (options) => {
|
|
2294
|
+
try {
|
|
2295
|
+
const apiClient = new DeSciXApiClient();
|
|
2296
|
+
await requireAuth(apiClient);
|
|
2297
|
+
const ids = options.ids ? options.ids.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
2298
|
+
const file_ids = options.fileIds ? options.fileIds.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
2299
|
+
if (ids.length === 0 && file_ids.length === 0) throw new Error('Provide --ids and/or --file-ids');
|
|
2300
|
+
const response = await apiClient.invoke(`${cmdPrefix}_delete`, { app_id: options.app, kb_id: options.kb, ids, file_ids });
|
|
2301
|
+
const result = response.message || response;
|
|
2302
|
+
console.log(chalk.green(`\n✓ ${result.message || 'records deleted'}\n`));
|
|
2303
|
+
} catch (error) {
|
|
2304
|
+
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
2305
|
+
process.exit(1);
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
// Canonical: `descix app records ...`
|
|
2311
|
+
const appRecordsCommand = appCommand
|
|
2312
|
+
.command('records')
|
|
2313
|
+
.description('Use your app as a queryable database (put/query/get/delete structured records with custom metadata). Backed by the app data plane (Firestore), not the Pinecone KB.');
|
|
2314
|
+
attachRecordsSubcommands(appRecordsCommand, 'app_records');
|
|
2315
|
+
|
|
2316
|
+
|
|
2317
|
+
// Corpus sub-commands (git-aware RAG sync via manifests)
|
|
2318
|
+
const corpusCommand = kbCommand
|
|
2319
|
+
.command('corpus')
|
|
2320
|
+
.description('Git-aware corpus sync via manifests');
|
|
2321
|
+
|
|
2322
|
+
corpusCommand
|
|
2323
|
+
.command('sync')
|
|
2324
|
+
.description('Sync corpus files to Pinecone using manifest definitions')
|
|
2325
|
+
.requiredOption('-a, --app <id>', 'App ID')
|
|
2326
|
+
.option('-c, --community <community_id>', 'Community ID (optional; resolved from Products when omitted)')
|
|
2327
|
+
.option('-k, --kb <name>', 'KB name (syncs specific manifest; default: all)')
|
|
1631
2328
|
.option('-v, --verbose', 'Show verbose output')
|
|
1632
|
-
.option('
|
|
1633
|
-
.option('--
|
|
1634
|
-
.option('--
|
|
1635
|
-
.option('--
|
|
2329
|
+
.option('--ref <ref>', 'Override the git ref for ALL manifest sources (e.g., --ref ws-admin-b1). Precedence: --ref > manifest source.ref > "main".')
|
|
2330
|
+
.option('--rebuild', 'Reconcile Pinecone against the current manifest walk: enumerate remote file_ids, purge any not in the current corpus, then re-sync from scratch. Use to recover from accumulated stale-chunk drift. Prompts before deleting unless --yes is supplied.')
|
|
2331
|
+
.option('--dry-run', 'Enumerate would-be-purged file_ids and would-be-upserted chunks without ANY Pinecone writes. Exit 0 if no drift, 1 if drift. Read-only.')
|
|
2332
|
+
.option('--show-walk', 'Print the resolved ref + the first 50 walked files BEFORE any Pinecone operations. Useful for verifying --ref / manifest source resolution.')
|
|
2333
|
+
.option('--yes', 'Skip the interactive purge confirmation in --rebuild mode. Use in scripting/CI.')
|
|
2334
|
+
.action(async (options) => {
|
|
2335
|
+
try {
|
|
2336
|
+
const apiClient = new DeSciXApiClient();
|
|
2337
|
+
await requireAuth(apiClient);
|
|
2338
|
+
|
|
2339
|
+
const result = await corpusCommands.runCorpusSync(apiClient, options);
|
|
2340
|
+
// Dry-run exit code per Deliverable A: 0 if no drift, 1 if drift would be applied.
|
|
2341
|
+
if (result && result.dryRun) {
|
|
2342
|
+
process.exit(result.drift ? 1 : 0);
|
|
2343
|
+
}
|
|
2344
|
+
} catch (error) {
|
|
2345
|
+
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
2346
|
+
process.exit(1);
|
|
2347
|
+
}
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
corpusCommand
|
|
2351
|
+
.command('status')
|
|
2352
|
+
.description('Show corpus sync state (files, chunks, last sync, resolved ref)')
|
|
2353
|
+
.requiredOption('-a, --app <id>', 'App ID')
|
|
2354
|
+
.option('-k, --kb <name>', 'KB name (default: all)')
|
|
2355
|
+
.option('--ref <ref>', 'Preview status as if --ref were applied to a sync (does not change manifests)')
|
|
2356
|
+
.option('-v, --verbose', 'Show verbose output including change detection')
|
|
1636
2357
|
.action(async (options) => {
|
|
1637
2358
|
try {
|
|
1638
2359
|
let apiClient = null;
|
|
@@ -1640,48 +2361,96 @@ kbCommand
|
|
|
1640
2361
|
apiClient = new DeSciXApiClient();
|
|
1641
2362
|
await apiClient.loadCredentials();
|
|
1642
2363
|
} catch {
|
|
1643
|
-
//
|
|
2364
|
+
// Status can work offline
|
|
1644
2365
|
}
|
|
1645
|
-
|
|
1646
|
-
await
|
|
1647
|
-
...options,
|
|
1648
|
-
moveToProcessed: options.move !== false
|
|
1649
|
-
});
|
|
2366
|
+
|
|
2367
|
+
await corpusCommands.runCorpusStatus(apiClient, options);
|
|
1650
2368
|
} catch (error) {
|
|
1651
2369
|
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
1652
2370
|
process.exit(1);
|
|
1653
2371
|
}
|
|
1654
2372
|
});
|
|
1655
2373
|
|
|
2374
|
+
// ============ KB Doctor (M3, 2026-04-20) ============
|
|
2375
|
+
// Compare local sync-state vs Pinecone vectorCount and scan recent sync
|
|
2376
|
+
// logs for per-file 0-chunk warnings. Exits non-zero on drift > threshold.
|
|
1656
2377
|
kbCommand
|
|
1657
|
-
.command('
|
|
1658
|
-
.description('
|
|
2378
|
+
.command('doctor')
|
|
2379
|
+
.description('Detect drift between local sync-state and live Pinecone vector count')
|
|
2380
|
+
.requiredOption('-a, --app <id>', 'App ID')
|
|
2381
|
+
.requiredOption('-k, --kb <name>', 'KB name')
|
|
2382
|
+
.option('-t, --threshold <ratio>', 'Drift threshold as a fraction (default: 0.05)', parseFloat)
|
|
2383
|
+
.option('--live', 'Compute vectorCount from the TRUE live Pinecone scope (bypass the cached counter, which lies after an interrupted op)')
|
|
2384
|
+
.option('--reconcile', 'Compute the live count AND write it back to rag_vector_count so the cached read is truthful again (implies --live)')
|
|
2385
|
+
.action(async (options) => {
|
|
2386
|
+
try {
|
|
2387
|
+
const apiClient = new DeSciXApiClient();
|
|
2388
|
+
await requireAuth(apiClient);
|
|
2389
|
+
await kbCommands.runKbDoctor(apiClient, options);
|
|
2390
|
+
} catch (error) {
|
|
2391
|
+
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
2392
|
+
process.exit(1);
|
|
2393
|
+
}
|
|
2394
|
+
});
|
|
2395
|
+
|
|
2396
|
+
// ============ Drive Commands ============
|
|
2397
|
+
|
|
2398
|
+
const driveCommand = program
|
|
2399
|
+
.command('drive')
|
|
2400
|
+
.description('Drive content authoring: pull from Drive, push staging to Drive');
|
|
2401
|
+
|
|
2402
|
+
driveCommand
|
|
2403
|
+
.command('pull')
|
|
2404
|
+
.description('Pull content from Drive and convert to local markdown')
|
|
1659
2405
|
.option('-c, --community <id>', 'Community ID')
|
|
1660
2406
|
.option('-a, --app <id>', 'App ID')
|
|
1661
2407
|
.option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
|
|
2408
|
+
.option('--folder <id_or_url>', 'Override Drive folder (raw ID or full Drive URL) — one-time import without modifying workspace.json')
|
|
2409
|
+
.option('-v, --verbose', 'Show verbose output')
|
|
2410
|
+
.option('--merge-mode <mode>', 'Merge mode: merge|overwrite|force-overwrite (default: merge)')
|
|
2411
|
+
.option('--dry-run', 'Show what would happen without making changes')
|
|
1662
2412
|
.action(async (options) => {
|
|
1663
2413
|
try {
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
2414
|
+
let apiClient = null;
|
|
2415
|
+
try {
|
|
2416
|
+
apiClient = new DeSciXApiClient();
|
|
2417
|
+
await apiClient.loadCredentials();
|
|
2418
|
+
} catch {
|
|
2419
|
+
// Continue without API client
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
await kbCommands.runKbPull(apiClient, options);
|
|
1668
2423
|
} catch (error) {
|
|
1669
2424
|
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
1670
2425
|
process.exit(1);
|
|
1671
2426
|
}
|
|
1672
2427
|
});
|
|
1673
2428
|
|
|
1674
|
-
|
|
1675
|
-
.command('
|
|
1676
|
-
.description('
|
|
2429
|
+
driveCommand
|
|
2430
|
+
.command('push')
|
|
2431
|
+
.description('Push staging files to Drive')
|
|
1677
2432
|
.option('-c, --community <id>', 'Community ID')
|
|
1678
2433
|
.option('-a, --app <id>', 'App ID')
|
|
1679
2434
|
.option('-k, --kb <id>', 'Knowledge Base ID (default: General)')
|
|
1680
|
-
.option('--
|
|
1681
|
-
.option('--
|
|
2435
|
+
.option('-v, --verbose', 'Show verbose output')
|
|
2436
|
+
.option('-i, --interactive', 'Enable interactive prompts for conflicts')
|
|
2437
|
+
.option('--on-conflict <action>', 'Conflict handling: overwrite|skip (default: overwrite)')
|
|
2438
|
+
.option('--no-move', 'Do not move files to .processed after upload')
|
|
2439
|
+
.option('--dry-run', 'Show what would happen without making changes')
|
|
1682
2440
|
.action(async (options) => {
|
|
1683
2441
|
try {
|
|
1684
|
-
|
|
2442
|
+
let apiClient = null;
|
|
2443
|
+
try {
|
|
2444
|
+
apiClient = new DeSciXApiClient();
|
|
2445
|
+
await apiClient.loadCredentials();
|
|
2446
|
+
} catch {
|
|
2447
|
+
// Continue without API client
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
await kbCommands.runKbPush(apiClient, {
|
|
2451
|
+
...options,
|
|
2452
|
+
moveToProcessed: options.move !== false
|
|
2453
|
+
});
|
|
1685
2454
|
} catch (error) {
|
|
1686
2455
|
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
1687
2456
|
process.exit(1);
|
|
@@ -1712,16 +2481,16 @@ siteCommand
|
|
|
1712
2481
|
process.exit(1);
|
|
1713
2482
|
}
|
|
1714
2483
|
|
|
1715
|
-
const appConfig = workspaceConfig.
|
|
2484
|
+
const appConfig = workspaceConfig.getAppByAppId(ctx.appId);
|
|
1716
2485
|
if (!appConfig) {
|
|
1717
2486
|
console.error(chalk.red('\n❌ App not found in workspace.json.'));
|
|
1718
2487
|
console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
|
|
1719
2488
|
process.exit(1);
|
|
1720
2489
|
}
|
|
1721
|
-
|
|
1722
|
-
const appPath = appConfig.absolutePath ||
|
|
2490
|
+
|
|
2491
|
+
const appPath = appConfig.absolutePath ||
|
|
1723
2492
|
path.join(workspaceConfig.getWorkspaceRoot(), appConfig.localPath);
|
|
1724
|
-
|
|
2493
|
+
|
|
1725
2494
|
console.log(chalk.cyan('\n📁 Adding site scaffold...\n'));
|
|
1726
2495
|
|
|
1727
2496
|
const { copyScaffold } = await import('../lib/core/Hydrator.js');
|
|
@@ -1740,13 +2509,13 @@ siteCommand
|
|
|
1740
2509
|
}
|
|
1741
2510
|
});
|
|
1742
2511
|
|
|
1743
|
-
// site upload - Deploy to GCS (context-aware,
|
|
2512
|
+
// site upload - Deploy to GCS (context-aware, manifest-aware)
|
|
1744
2513
|
siteCommand
|
|
1745
2514
|
.command('upload')
|
|
1746
|
-
.description('Upload site to GCS and update app metadata')
|
|
2515
|
+
.description('Upload site to GCS and update app metadata (uses .descix/manifests/site.json when present)')
|
|
1747
2516
|
.option('-c, --community <id>', 'Community ID (auto-detects from context)')
|
|
1748
2517
|
.option('-a, --app <id>', 'App ID (auto-detects from context)')
|
|
1749
|
-
.option('-p, --path <localPath>', 'Local directory to deploy', './site')
|
|
2518
|
+
.option('-p, --path <localPath>', 'Local directory to deploy (ignored when site manifest exists)', './site')
|
|
1750
2519
|
.option('--preview', 'Deploy to preview path')
|
|
1751
2520
|
.option('--full', 'Force full upload (ignore delta)')
|
|
1752
2521
|
.option('--dry-run', 'Show what would be deployed')
|
|
@@ -1754,93 +2523,190 @@ siteCommand
|
|
|
1754
2523
|
.action(async (options) => {
|
|
1755
2524
|
try {
|
|
1756
2525
|
const apiClient = new DeSciXApiClient();
|
|
2526
|
+
|
|
2527
|
+
// WS-DEPLOY-HARDENING item 7: `site upload` must never target a local backend by
|
|
2528
|
+
// inheritance. detectApiUrl() checks process.env.DESCIX_API_URL first, then
|
|
2529
|
+
// WorkspaceConfig.getApiUrl() (env.apiUrl, else the shipped PROD default), then
|
|
2530
|
+
// GlobalConfig. `--env` and `--api-url` are folded into DESCIX_API_URL by the
|
|
2531
|
+
// preAction hook BEFORE this action runs — so checking DESCIX_API_URL here reflects
|
|
2532
|
+
// whether THIS invocation named its target. A workspace pinned at a local backend
|
|
2533
|
+
// (env.apiUrl = https://localhost:4000) is a standing setting, not a deploy target:
|
|
2534
|
+
// site deploys go to a cloud env, so that combination is a hard failure.
|
|
2535
|
+
const resolvedApiUrl = await apiClient.ensureBaseUrl();
|
|
2536
|
+
const isLocalApiUrl = /^https?:\/\/(localhost|127(?:\.\d{1,3}){3})(?::\d+)?/i.test(resolvedApiUrl) ||
|
|
2537
|
+
/:4000(?:\/|$)/.test(resolvedApiUrl);
|
|
2538
|
+
const explicitlyResolvedViaEnv = !!process.env.DESCIX_API_URL;
|
|
2539
|
+
if (isLocalApiUrl && !explicitlyResolvedViaEnv) {
|
|
2540
|
+
console.error(chalk.red(
|
|
2541
|
+
`\n❌ Refusing site upload against a local backend (${resolvedApiUrl}). ` +
|
|
2542
|
+
`Site deploys target a cloud env — pass --env=<dev|demo|prod> or export DESCIX_API_URL=https://<env>.descix.net\n`
|
|
2543
|
+
));
|
|
2544
|
+
process.exit(1);
|
|
2545
|
+
}
|
|
2546
|
+
|
|
1757
2547
|
await requireAuth(apiClient);
|
|
1758
|
-
|
|
2548
|
+
|
|
1759
2549
|
// Load workspace context
|
|
1760
2550
|
const workspaceConfig = await WorkspaceConfig.load();
|
|
1761
2551
|
const ctx = workspaceConfig.resolveContextWithOptions(options);
|
|
1762
|
-
|
|
1763
|
-
|
|
2552
|
+
|
|
2553
|
+
let communityId = ctx.communityId;
|
|
1764
2554
|
const appId = ctx.appId;
|
|
1765
|
-
|
|
1766
|
-
if (!
|
|
1767
|
-
console.error(chalk.red('\n❌
|
|
1768
|
-
console.log(chalk.gray(' Either provide -
|
|
2555
|
+
|
|
2556
|
+
if (!appId) {
|
|
2557
|
+
console.error(chalk.red('\n❌ App ID required.'));
|
|
2558
|
+
console.log(chalk.gray(' Either provide -a flag, or cd into an app directory\n'));
|
|
1769
2559
|
process.exit(1);
|
|
1770
2560
|
}
|
|
1771
|
-
|
|
1772
|
-
//
|
|
1773
|
-
const
|
|
2561
|
+
|
|
2562
|
+
// Resolve app root from workspace config
|
|
2563
|
+
const appConfig = workspaceConfig.getAppByAppId(appId);
|
|
2564
|
+
|
|
2565
|
+
// Derive communityId from workspace config if not provided.
|
|
2566
|
+
// Fallback: for NFT apps, community_id === app_id (convention).
|
|
2567
|
+
// The backend will validate this via hydrateCommunityIdFromProducts.
|
|
2568
|
+
if (!communityId) {
|
|
2569
|
+
communityId = appConfig?.communityId || appId;
|
|
2570
|
+
}
|
|
2571
|
+
const appRoot = appConfig?.absolutePath || path.resolve('.');
|
|
2572
|
+
|
|
2573
|
+
// Check for site manifest (.descix/manifests/site.json)
|
|
2574
|
+
const { loadSiteManifest } = await import('../lib/core/ManifestLoader.js');
|
|
2575
|
+
const siteManifest = await loadSiteManifest(appRoot);
|
|
2576
|
+
|
|
2577
|
+
// Import mime for content types
|
|
1774
2578
|
const mime = (await import('mime-types')).default;
|
|
1775
|
-
|
|
1776
|
-
const
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
2579
|
+
const { GitUtils } = await import('@descix/sdk/integrations/git');
|
|
2580
|
+
const gitUtils = new GitUtils(process.cwd());
|
|
2581
|
+
const gitStatus = await gitUtils.getStatus();
|
|
2582
|
+
|
|
2583
|
+
let fileList; // Array<{ path, hash, size, content_type }>
|
|
2584
|
+
let localFiles; // Object for delta comparison { path: { hash, size } }
|
|
2585
|
+
let sourceLabel; // For display
|
|
2586
|
+
|
|
2587
|
+
if (siteManifest) {
|
|
2588
|
+
// ── Manifest-driven flow ──
|
|
2589
|
+
const { walkSite } = await import('../lib/core/SiteWalker.js');
|
|
2590
|
+
|
|
2591
|
+
// Get env-specific build config from backend (GTM vars + base path)
|
|
2592
|
+
const buildConfigResult = await apiClient.invoke('get_site_build_config', {
|
|
2593
|
+
app_id: appId,
|
|
2594
|
+
community_id: communityId
|
|
2595
|
+
});
|
|
2596
|
+
// Canonical-contract ferry (engineering-culture mandate 2026-06-18): the server's
|
|
2597
|
+
// get_site_build_config owns the build-env schema — forward EVERY VITE_* key it
|
|
2598
|
+
// returns instead of hand-mirroring the field list (drift bug class; a hand mirror
|
|
2599
|
+
// silently dropped VITE_POWCH_FIRST_PARTY_ORIGINS, baking deny-by-default [] into
|
|
2600
|
+
// the Powch PWA and killing the first-party email ToS auto-grant).
|
|
2601
|
+
const buildEnvVars = Object.fromEntries(
|
|
2602
|
+
Object.entries(buildConfigResult.message || {}).filter(([k]) => k.startsWith('VITE_'))
|
|
2603
|
+
);
|
|
2604
|
+
console.log(chalk.cyan(` Build env: base=${buildConfigResult.message.VITE_BASE_PATH}, gtm=${buildConfigResult.message.VITE_GTM_ID}\n`));
|
|
2605
|
+
|
|
2606
|
+
// Run buildCommand if specified
|
|
2607
|
+
if (siteManifest.buildCommand) {
|
|
2608
|
+
if (options.dryRun) {
|
|
2609
|
+
console.log(chalk.yellow(`\n Dry run - skipping build (${siteManifest.buildCommand})`));
|
|
2610
|
+
console.log(chalk.gray(' Build env vars that would be injected:'));
|
|
2611
|
+
Object.entries(buildEnvVars).forEach(([k, v]) => console.log(chalk.gray(` ${k}=${v}`)));
|
|
2612
|
+
} else {
|
|
2613
|
+
console.log(chalk.cyan(`\n Running build: ${siteManifest.buildCommand}\n`));
|
|
2614
|
+
const { exec } = await import('child_process');
|
|
2615
|
+
const { promisify } = await import('util');
|
|
2616
|
+
const execAsync = promisify(exec);
|
|
2617
|
+
await execAsync(siteManifest.buildCommand, {
|
|
2618
|
+
cwd: appRoot,
|
|
2619
|
+
env: { ...process.env, ...buildEnvVars }
|
|
2620
|
+
});
|
|
2621
|
+
console.log(chalk.green(' Build completed.\n'));
|
|
2622
|
+
}
|
|
1783
2623
|
}
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
2624
|
+
|
|
2625
|
+
const walkResult = await walkSite(siteManifest, appRoot);
|
|
2626
|
+
sourceLabel = `manifest (${siteManifest._manifestPath})`;
|
|
2627
|
+
|
|
2628
|
+
fileList = walkResult.files.map(f => ({
|
|
2629
|
+
path: f.deployPath,
|
|
2630
|
+
hash: f.hash,
|
|
2631
|
+
size: f.size,
|
|
2632
|
+
content_type: mime.lookup(f.deployPath) || 'application/octet-stream',
|
|
2633
|
+
_absolutePath: f.absolutePath // kept for upload reads
|
|
2634
|
+
}));
|
|
2635
|
+
|
|
2636
|
+
// Build localFiles object for delta comparison
|
|
2637
|
+
localFiles = {};
|
|
2638
|
+
for (const f of fileList) {
|
|
2639
|
+
localFiles[f.path] = { hash: f.hash, size: f.size };
|
|
1787
2640
|
}
|
|
1788
|
-
|
|
2641
|
+
} else {
|
|
2642
|
+
// ── Legacy flat-directory flow (unchanged) ──
|
|
2643
|
+
const localPath = path.resolve(options.path);
|
|
2644
|
+
sourceLabel = localPath;
|
|
2645
|
+
|
|
2646
|
+
try {
|
|
2647
|
+
const stat = await fs.stat(localPath);
|
|
2648
|
+
if (!stat.isDirectory()) {
|
|
2649
|
+
throw new Error(`Path is not a directory: ${localPath}`);
|
|
2650
|
+
}
|
|
2651
|
+
} catch (err) {
|
|
2652
|
+
if (err.code === 'ENOENT') {
|
|
2653
|
+
throw new Error(`Directory not found: ${localPath}`);
|
|
2654
|
+
}
|
|
2655
|
+
throw err;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
const rawFiles = await gitUtils.getFileHashes(localPath);
|
|
2659
|
+
localFiles = rawFiles;
|
|
2660
|
+
fileList = Object.entries(rawFiles).map(([filePath, info]) => ({
|
|
2661
|
+
path: filePath,
|
|
2662
|
+
hash: info.hash,
|
|
2663
|
+
size: info.size,
|
|
2664
|
+
content_type: mime.lookup(filePath) || 'application/octet-stream',
|
|
2665
|
+
_absolutePath: path.join(localPath, filePath)
|
|
2666
|
+
}));
|
|
1789
2667
|
}
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
const gitStatus = await gitUtils.getStatus();
|
|
1794
|
-
|
|
1795
|
-
console.log(chalk.cyan(`\n🚀 Site Upload: ${localPath} → GCS\n`));
|
|
2668
|
+
|
|
2669
|
+
console.log(chalk.cyan(`\n Site Upload: ${appId} → GCS\n`));
|
|
2670
|
+
console.log(chalk.gray(` Source: ${sourceLabel}`));
|
|
1796
2671
|
console.log(chalk.gray(` Target: ${communityId}/${appId}`));
|
|
1797
2672
|
if (options.preview) console.log(chalk.yellow(` Mode: PREVIEW`));
|
|
1798
2673
|
if (gitStatus.isGitRepo) {
|
|
1799
2674
|
console.log(chalk.gray(` Git: ${gitStatus.branch} @ ${gitStatus.shortHash}`));
|
|
1800
2675
|
}
|
|
1801
|
-
|
|
1802
|
-
// 1. Compute local file hashes
|
|
1803
|
-
const localFiles = await gitUtils.getFileHashes(localPath);
|
|
1804
|
-
const fileList = Object.entries(localFiles).map(([filePath, info]) => ({
|
|
1805
|
-
path: filePath,
|
|
1806
|
-
hash: info.hash,
|
|
1807
|
-
size: info.size,
|
|
1808
|
-
content_type: mime.lookup(filePath) || 'application/octet-stream'
|
|
1809
|
-
}));
|
|
1810
|
-
|
|
2676
|
+
|
|
1811
2677
|
console.log(chalk.gray(`\n Found ${fileList.length} files\n`));
|
|
1812
|
-
|
|
2678
|
+
|
|
1813
2679
|
if (fileList.length === 0) {
|
|
1814
|
-
console.log(chalk.yellow(' No files found
|
|
2680
|
+
console.log(chalk.yellow(' No files found. Nothing to deploy.\n'));
|
|
1815
2681
|
return;
|
|
1816
2682
|
}
|
|
1817
|
-
|
|
2683
|
+
|
|
1818
2684
|
// 2. Request deploy token with file list
|
|
1819
2685
|
const tokenResponse = await apiClient.invoke('get_site_deploy_token', {
|
|
1820
2686
|
community_id: communityId,
|
|
1821
2687
|
app_id: appId,
|
|
1822
|
-
files: fileList,
|
|
2688
|
+
files: fileList.map(f => ({ path: f.path, hash: f.hash, size: f.size, content_type: f.content_type })),
|
|
1823
2689
|
preview: options.preview || false
|
|
1824
2690
|
});
|
|
1825
|
-
|
|
1826
|
-
const { signed_urls, existing_manifest, token_id, site_url } = tokenResponse.message;
|
|
1827
|
-
|
|
2691
|
+
|
|
2692
|
+
const { signed_urls, upload_headers, existing_manifest, token_id, site_url } = tokenResponse.message;
|
|
2693
|
+
|
|
1828
2694
|
// 3. Determine delta
|
|
1829
2695
|
let filesToUpload = fileList;
|
|
1830
2696
|
let filesToDelete = [];
|
|
1831
|
-
|
|
2697
|
+
|
|
1832
2698
|
if (!options.full && existing_manifest) {
|
|
1833
2699
|
const delta = gitUtils.compareSyncState(localFiles, existing_manifest);
|
|
1834
|
-
filesToUpload = fileList.filter(f =>
|
|
2700
|
+
filesToUpload = fileList.filter(f =>
|
|
1835
2701
|
delta.added.includes(f.path) || delta.modified.includes(f.path)
|
|
1836
2702
|
);
|
|
1837
2703
|
filesToDelete = delta.deleted;
|
|
1838
|
-
|
|
2704
|
+
|
|
1839
2705
|
console.log(chalk.gray(` Delta: ${delta.added.length} added, ${delta.modified.length} modified, ${delta.unchanged.length} unchanged, ${delta.deleted.length} deleted`));
|
|
1840
2706
|
}
|
|
1841
|
-
|
|
2707
|
+
|
|
1842
2708
|
if (options.dryRun) {
|
|
1843
|
-
console.log(chalk.yellow('\n
|
|
2709
|
+
console.log(chalk.yellow('\n Dry run - would upload:'));
|
|
1844
2710
|
if (filesToUpload.length === 0) {
|
|
1845
2711
|
console.log(chalk.gray(' (no files to upload)'));
|
|
1846
2712
|
} else {
|
|
@@ -1853,56 +2719,66 @@ siteCommand
|
|
|
1853
2719
|
console.log(chalk.gray(`\n Site URL: ${site_url}\n`));
|
|
1854
2720
|
return;
|
|
1855
2721
|
}
|
|
1856
|
-
|
|
2722
|
+
|
|
1857
2723
|
if (filesToUpload.length === 0) {
|
|
1858
|
-
console.log(chalk.green('\n
|
|
2724
|
+
console.log(chalk.green('\n Site already up to date on GCS.'));
|
|
1859
2725
|
console.log(chalk.gray(' Ensuring app metadata is synchronized...'));
|
|
1860
|
-
// Fall through to confirm_site_deploy to ensure DB is updated
|
|
2726
|
+
// Fall through to confirm_site_deploy to ensure DB is updated
|
|
1861
2727
|
} else {
|
|
1862
2728
|
// 4. Upload files directly to GCS using signed URLs
|
|
1863
2729
|
console.log(chalk.gray(`\n Uploading ${filesToUpload.length} files...`));
|
|
1864
|
-
|
|
2730
|
+
|
|
1865
2731
|
let uploadedCount = 0;
|
|
1866
2732
|
let uploadErrors = [];
|
|
1867
|
-
|
|
2733
|
+
|
|
1868
2734
|
for (const file of filesToUpload) {
|
|
1869
2735
|
const signedUrl = signed_urls[file.path];
|
|
1870
2736
|
if (!signedUrl) {
|
|
1871
2737
|
uploadErrors.push(`No signed URL for: ${file.path}`);
|
|
1872
2738
|
continue;
|
|
1873
2739
|
}
|
|
1874
|
-
|
|
2740
|
+
|
|
1875
2741
|
try {
|
|
1876
|
-
const content = await fs.readFile(
|
|
1877
|
-
|
|
2742
|
+
const content = await fs.readFile(file._absolutePath);
|
|
2743
|
+
|
|
2744
|
+
// Canonical contract (WS-DEPLOY-HARDENING item 4): the server binds Cache-Control
|
|
2745
|
+
// into the v4 signed URL signature (generateSiteUploadUrls/siteAssetCacheControl).
|
|
2746
|
+
// Ferry the exact header bag the server minted — never re-derive the policy
|
|
2747
|
+
// client-side. A mismatched header (including a client-side --no-cache override)
|
|
2748
|
+
// makes GCS return 403 signature-mismatch, so once the server ferries a bound
|
|
2749
|
+
// header for this path, --no-cache is a no-op for it (falls back to legacy
|
|
2750
|
+
// Content-Type-only behavior for paths the server didn't ferry a header for).
|
|
2751
|
+
const ferriedHeaders = upload_headers && upload_headers[file.path];
|
|
2752
|
+
const headers = ferriedHeaders || {
|
|
2753
|
+
'Content-Type': file.content_type,
|
|
2754
|
+
...(options.noCache ? { 'Cache-Control': 'no-cache' } : {})
|
|
2755
|
+
};
|
|
2756
|
+
|
|
1878
2757
|
const response = await fetch(signedUrl, {
|
|
1879
2758
|
method: 'PUT',
|
|
1880
|
-
headers
|
|
1881
|
-
'Content-Type': file.content_type,
|
|
1882
|
-
...(options.noCache ? { 'Cache-Control': 'no-cache' } : {})
|
|
1883
|
-
},
|
|
2759
|
+
headers,
|
|
1884
2760
|
body: content
|
|
1885
2761
|
});
|
|
1886
|
-
|
|
2762
|
+
|
|
1887
2763
|
if (!response.ok) {
|
|
1888
2764
|
uploadErrors.push(`Failed to upload ${file.path}: ${response.status} ${response.statusText}`);
|
|
1889
|
-
console.log(chalk.red(`
|
|
2765
|
+
console.log(chalk.red(` x ${file.path}`));
|
|
1890
2766
|
} else {
|
|
1891
2767
|
uploadedCount++;
|
|
1892
|
-
console.log(chalk.green(`
|
|
2768
|
+
console.log(chalk.green(` + ${file.path}`));
|
|
1893
2769
|
}
|
|
1894
2770
|
} catch (err) {
|
|
1895
2771
|
uploadErrors.push(`Error uploading ${file.path}: ${err.message}`);
|
|
1896
|
-
console.log(chalk.red(`
|
|
2772
|
+
console.log(chalk.red(` x ${file.path}`));
|
|
1897
2773
|
}
|
|
1898
2774
|
}
|
|
1899
|
-
|
|
2775
|
+
|
|
1900
2776
|
if (uploadErrors.length > 0) {
|
|
1901
|
-
console.log(chalk.yellow(`\n
|
|
2777
|
+
console.log(chalk.yellow(`\n ${uploadErrors.length} errors during upload:`));
|
|
1902
2778
|
uploadErrors.forEach(e => console.log(chalk.red(` - ${e}`)));
|
|
1903
2779
|
}
|
|
1904
2780
|
}
|
|
1905
|
-
|
|
2781
|
+
|
|
1906
2782
|
// 5. Confirm deployment (ALWAYS call this to ensure DB is updated)
|
|
1907
2783
|
const confirmResponse = await apiClient.invoke('confirm_site_deploy', {
|
|
1908
2784
|
community_id: communityId,
|
|
@@ -1913,7 +2789,7 @@ siteCommand
|
|
|
1913
2789
|
app_id: appId,
|
|
1914
2790
|
community_id: communityId,
|
|
1915
2791
|
files: localFiles,
|
|
1916
|
-
source_path: options.path
|
|
2792
|
+
source_path: siteManifest ? 'manifest' : options.path
|
|
1917
2793
|
},
|
|
1918
2794
|
git_info: gitStatus.isGitRepo ? {
|
|
1919
2795
|
commit: gitStatus.shortHash,
|
|
@@ -1921,25 +2797,25 @@ siteCommand
|
|
|
1921
2797
|
} : null,
|
|
1922
2798
|
preview: options.preview || false
|
|
1923
2799
|
});
|
|
1924
|
-
|
|
2800
|
+
|
|
1925
2801
|
const result = confirmResponse.message;
|
|
1926
|
-
|
|
1927
|
-
console.log(chalk.green(`\n
|
|
2802
|
+
|
|
2803
|
+
console.log(chalk.green(`\n Site uploaded successfully!\n`));
|
|
1928
2804
|
console.log(chalk.cyan(` URL: ${result.site_url}`));
|
|
1929
2805
|
console.log(chalk.gray(` Files: ${result.files_count}`));
|
|
1930
2806
|
console.log(chalk.gray(` Deployed: ${result.deployed_at}`));
|
|
1931
2807
|
if (result.preview) {
|
|
1932
2808
|
console.log(chalk.yellow(` Mode: PREVIEW`));
|
|
1933
2809
|
}
|
|
1934
|
-
|
|
2810
|
+
|
|
1935
2811
|
if (!options.preview) {
|
|
1936
2812
|
console.log(chalk.gray(`\n For local development, run:`));
|
|
1937
2813
|
console.log(chalk.cyan(` descix site servelocal <port>\n`));
|
|
1938
2814
|
}
|
|
1939
2815
|
console.log('');
|
|
1940
|
-
|
|
2816
|
+
|
|
1941
2817
|
} catch (error) {
|
|
1942
|
-
console.error(chalk.red(`\n
|
|
2818
|
+
console.error(chalk.red(`\n Upload failed: ${error.message}\n`));
|
|
1943
2819
|
process.exit(1);
|
|
1944
2820
|
}
|
|
1945
2821
|
});
|
|
@@ -1954,68 +2830,61 @@ siteCommand
|
|
|
1954
2830
|
try {
|
|
1955
2831
|
const workspaceConfig = await WorkspaceConfig.load();
|
|
1956
2832
|
const ctx = workspaceConfig.resolveContextWithOptions(options);
|
|
1957
|
-
|
|
1958
|
-
const communityId = ctx.communityId;
|
|
2833
|
+
|
|
1959
2834
|
const appId = ctx.appId;
|
|
1960
|
-
|
|
1961
|
-
if (!
|
|
1962
|
-
console.error(chalk.red('\n❌
|
|
1963
|
-
console.log(chalk.gray(' Either provide -
|
|
1964
|
-
process.exit(1);
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
const appConfig = workspaceConfig.getApp(communityId, appId);
|
|
1968
|
-
if (!appConfig) {
|
|
1969
|
-
console.error(chalk.red('\n❌ App not found in workspace.json.'));
|
|
1970
|
-
console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
|
|
2835
|
+
|
|
2836
|
+
if (!appId) {
|
|
2837
|
+
console.error(chalk.red('\n❌ App ID required.'));
|
|
2838
|
+
console.log(chalk.gray(' Either provide -a flag, or cd into an app directory\n'));
|
|
1971
2839
|
process.exit(1);
|
|
1972
2840
|
}
|
|
1973
|
-
|
|
1974
|
-
// Handle disable case
|
|
2841
|
+
|
|
2842
|
+
// Handle disable case — setSitePort(appId, null) removes site.port / cleans site.{}
|
|
1975
2843
|
if (port === 'n' || port === 'N') {
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
if (Object.keys(appConfig.site).length === 0) {
|
|
1979
|
-
delete appConfig.site;
|
|
1980
|
-
}
|
|
1981
|
-
}
|
|
1982
|
-
await workspaceConfig.save();
|
|
1983
|
-
console.log(chalk.green(`\n✅ Local site server disabled for ${communityId}/${appId}\n`));
|
|
2844
|
+
await workspaceConfig.setSitePort(appId, null);
|
|
2845
|
+
console.log(chalk.green(`\n✅ Local site server disabled for ${appId}\n`));
|
|
1984
2846
|
return;
|
|
1985
2847
|
}
|
|
1986
|
-
|
|
1987
|
-
// Handle port
|
|
2848
|
+
|
|
2849
|
+
// Handle status query (no port argument)
|
|
1988
2850
|
if (!port) {
|
|
1989
|
-
|
|
1990
|
-
|
|
2851
|
+
const appConfig = workspaceConfig.getAppByAppId(appId);
|
|
2852
|
+
if (!appConfig) {
|
|
2853
|
+
console.error(chalk.red('\n❌ App not found in workspace.json.'));
|
|
2854
|
+
console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
|
|
2855
|
+
process.exit(1);
|
|
2856
|
+
}
|
|
2857
|
+
// Read site.port from the live env entry (not from the constructed copy)
|
|
2858
|
+
let liveEntry = null;
|
|
2859
|
+
if (workspaceConfig.env?.platform?.appId === appId) {
|
|
2860
|
+
liveEntry = workspaceConfig.env.platform;
|
|
2861
|
+
} else if (Array.isArray(workspaceConfig.env?.products)) {
|
|
2862
|
+
liveEntry = workspaceConfig.env.products.find(p => p.appId === appId) || null;
|
|
2863
|
+
}
|
|
2864
|
+
const currentPort = liveEntry?.site?.port;
|
|
1991
2865
|
if (currentPort) {
|
|
1992
2866
|
console.log(chalk.cyan(`\n📍 Local site server: port ${currentPort}`));
|
|
1993
|
-
console.log(chalk.gray(` App: ${
|
|
2867
|
+
console.log(chalk.gray(` App: ${appId}\n`));
|
|
1994
2868
|
} else {
|
|
1995
2869
|
console.log(chalk.yellow(`\n⚠️ No local site server configured.`));
|
|
1996
2870
|
console.log(chalk.gray(` Usage: descix site servelocal <port>\n`));
|
|
1997
2871
|
}
|
|
1998
2872
|
return;
|
|
1999
2873
|
}
|
|
2000
|
-
|
|
2874
|
+
|
|
2001
2875
|
const portNum = parseInt(port);
|
|
2002
2876
|
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
2003
2877
|
console.error(chalk.red('\n❌ Invalid port number.\n'));
|
|
2004
2878
|
process.exit(1);
|
|
2005
2879
|
}
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
if (!appConfig.site.devCommand) {
|
|
2010
|
-
appConfig.site.devCommand = 'npm run dev';
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
await workspaceConfig.save();
|
|
2880
|
+
|
|
2881
|
+
// setSitePort mutates the live env entry and saves — no stale-copy problem
|
|
2882
|
+
await workspaceConfig.setSitePort(appId, portNum);
|
|
2014
2883
|
console.log(chalk.green(`\n✅ Local site server registered!`));
|
|
2015
2884
|
console.log(chalk.cyan(` Port: ${portNum}`));
|
|
2016
|
-
console.log(chalk.gray(` App: ${
|
|
2017
|
-
console.log(chalk.gray(`\n The
|
|
2018
|
-
|
|
2885
|
+
console.log(chalk.gray(` App: ${appId}`));
|
|
2886
|
+
console.log(chalk.gray(`\n The gateway will proxy /p/${appId}/* to localhost:${portNum}\n`));
|
|
2887
|
+
|
|
2019
2888
|
} catch (error) {
|
|
2020
2889
|
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
2021
2890
|
process.exit(1);
|
|
@@ -2221,42 +3090,99 @@ microserviceCommand
|
|
|
2221
3090
|
process.exit(1);
|
|
2222
3091
|
}
|
|
2223
3092
|
|
|
2224
|
-
const appConfig = workspaceConfig.
|
|
3093
|
+
const appConfig = workspaceConfig.getAppByAppId(ctx.appId);
|
|
2225
3094
|
if (!appConfig) {
|
|
2226
3095
|
console.error(chalk.red('\n❌ App not found in workspace.json.'));
|
|
2227
3096
|
console.log(chalk.gray(' Run "npx descix init" to set up your workspace.\n'));
|
|
2228
3097
|
process.exit(1);
|
|
2229
3098
|
}
|
|
2230
|
-
|
|
2231
|
-
const appPath = appConfig.absolutePath ||
|
|
3099
|
+
|
|
3100
|
+
const appPath = appConfig.absolutePath ||
|
|
2232
3101
|
path.join(workspaceConfig.getWorkspaceRoot(), appConfig.localPath);
|
|
2233
|
-
|
|
3102
|
+
|
|
3103
|
+
// Resolve microservice port from workspace.json before scaffold copy.
|
|
3104
|
+
// Hard-fail early if missing — a scaffolded microservice without a known port
|
|
3105
|
+
// cannot be routed by the gateway and cannot start on a deterministic port.
|
|
3106
|
+
const env = workspaceConfig.env || {};
|
|
3107
|
+
let microservicePort = null;
|
|
3108
|
+
if (env.platform?.appId === ctx.appId) {
|
|
3109
|
+
microservicePort = env.platform?.microservice?.port || null;
|
|
3110
|
+
} else if (Array.isArray(env.products)) {
|
|
3111
|
+
const productEntry = env.products.find(p => p.appId === ctx.appId);
|
|
3112
|
+
microservicePort = productEntry?.microservice?.port || null;
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
if (!microservicePort) {
|
|
3116
|
+
console.error(chalk.red(`\n❌ App '${ctx.appId}' has no microservice.port in workspace.json.`));
|
|
3117
|
+
console.error(chalk.red(` A port is required so the gateway knows where to route traffic.`));
|
|
3118
|
+
console.log(chalk.gray(`\n Set it with the config verb (never hand-edit workspace.json):`));
|
|
3119
|
+
console.log(chalk.gray(` descix app set-port -a ${ctx.appId} --port <your-port>\n`));
|
|
3120
|
+
process.exit(1);
|
|
3121
|
+
}
|
|
3122
|
+
|
|
2234
3123
|
console.log(chalk.cyan('\n📁 Adding microservice scaffold...\n'));
|
|
2235
|
-
|
|
3124
|
+
|
|
2236
3125
|
const { copyScaffold } = await import('../lib/core/Hydrator.js');
|
|
2237
|
-
const stats = await copyScaffold('microservice', appPath, {
|
|
2238
|
-
verbose: true,
|
|
2239
|
-
force: options.force
|
|
3126
|
+
const stats = await copyScaffold('microservice', appPath, {
|
|
3127
|
+
verbose: true,
|
|
3128
|
+
force: options.force
|
|
2240
3129
|
});
|
|
2241
|
-
|
|
3130
|
+
|
|
2242
3131
|
// Configuration Injection
|
|
2243
3132
|
const microserviceDir = path.join(appPath, 'microservice');
|
|
2244
3133
|
const defaultsPath = path.join(microserviceDir, 'defaults-config.json');
|
|
3134
|
+
const manifestPath = path.join(microserviceDir, 'manifest.json');
|
|
2245
3135
|
const overridesPath = path.join(microserviceDir, 'dev-overrides.json');
|
|
2246
|
-
|
|
2247
|
-
// 1. Inject Context into defaults-config.json
|
|
3136
|
+
|
|
3137
|
+
// 1. Inject Context + Port into defaults-config.json
|
|
2248
3138
|
try {
|
|
2249
3139
|
const defaultsContent = await fs.readFile(defaultsPath, 'utf-8');
|
|
2250
3140
|
const defaults = JSON.parse(defaultsContent);
|
|
2251
3141
|
defaults.community_id = ctx.communityId;
|
|
2252
3142
|
defaults.app_id = ctx.appId;
|
|
3143
|
+
defaults.LOCAL_PORT = microservicePort;
|
|
2253
3144
|
await fs.writeFile(defaultsPath, JSON.stringify(defaults, null, 2));
|
|
2254
|
-
console.log(chalk.gray(` ✓ Injected context into defaults-config.json`));
|
|
3145
|
+
console.log(chalk.gray(` ✓ Injected context + port into defaults-config.json`));
|
|
2255
3146
|
} catch (err) {
|
|
2256
3147
|
console.warn(chalk.yellow(` ⚠ Could not update defaults-config.json: ${err.message}`));
|
|
2257
3148
|
}
|
|
2258
|
-
|
|
2259
|
-
//
|
|
3149
|
+
|
|
3150
|
+
// 1b. Claim the scaffold's package.json for THIS service.
|
|
3151
|
+
// The template ships as "@descix/service-starter" — a real package name that is not
|
|
3152
|
+
// this developer's. Left unclaimed, every scaffolded service on the machine reports the
|
|
3153
|
+
// same identity to npm and to anything reading package.name, and `npm install` in the
|
|
3154
|
+
// service resolves against a name the developer does not own.
|
|
3155
|
+
try {
|
|
3156
|
+
const pkgPath = path.join(microserviceDir, 'package.json');
|
|
3157
|
+
const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'));
|
|
3158
|
+
pkg.name = ctx.appId;
|
|
3159
|
+
pkg.description = `${ctx.appId} microservice on the DeSciX mesh`;
|
|
3160
|
+
pkg.version = '0.1.0';
|
|
3161
|
+
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
3162
|
+
console.log(chalk.gray(` ✓ Claimed package.json as '${ctx.appId}'`));
|
|
3163
|
+
} catch (err) {
|
|
3164
|
+
console.warn(chalk.yellow(` ⚠ Could not claim package.json: ${err.message}`));
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
// 2. Inject Context + Port into manifest.json
|
|
3168
|
+
try {
|
|
3169
|
+
const manifestContent = await fs.readFile(manifestPath, 'utf-8');
|
|
3170
|
+
const manifest = JSON.parse(manifestContent);
|
|
3171
|
+
manifest.service.app_id = ctx.appId;
|
|
3172
|
+
manifest.service.community_id = ctx.communityId;
|
|
3173
|
+
manifest.service.name = ctx.appId;
|
|
3174
|
+
// No domain is injected. A service does not declare its own domain — the platform derives
|
|
3175
|
+
// it ({app_id}.{SITE_DOMAIN}) at registration, and refuses a manifest that declares one.
|
|
3176
|
+
// The literal this replaced was `${ctx.appId}.descix.net`: the PROD host, scaffolded into
|
|
3177
|
+
// every service regardless of the env it would be registered against.
|
|
3178
|
+
manifest.service.debugPort = microservicePort;
|
|
3179
|
+
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
3180
|
+
console.log(chalk.gray(` ✓ Injected context + port into manifest.json`));
|
|
3181
|
+
} catch (err) {
|
|
3182
|
+
console.warn(chalk.yellow(` ⚠ Could not update manifest.json: ${err.message}`));
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
// 3. Inject Credentials into dev-overrides.json
|
|
2260
3186
|
try {
|
|
2261
3187
|
const apiClient = new DeSciXApiClient();
|
|
2262
3188
|
const credentials = await apiClient.loadCredentials();
|
|
@@ -2294,10 +3220,132 @@ microserviceCommand
|
|
|
2294
3220
|
}
|
|
2295
3221
|
});
|
|
2296
3222
|
|
|
3223
|
+
// microservice deploy - Cloud Run deploy (broker-first; no per-app LB for non-core apps)
|
|
3224
|
+
// WS-MCP-SURFACE-SPLIT §4b: this is the REAL Admin/local deploy (Admin SDK) — it stays fully
|
|
3225
|
+
// functional for operator/CI use (EVP/COS deploy runbooks invoke it). It is intentionally NOT
|
|
3226
|
+
// advertised over MCP (not mcp:true), so the PUBLIC surface sees no deploy tool. The public
|
|
3227
|
+
// "microservice deploy" is "coming soon" (a no-op) — satisfied here by absence from the MCP
|
|
3228
|
+
// listing, NOT by neutering this admin path. Do not stub this out.
|
|
3229
|
+
microserviceCommand
|
|
3230
|
+
.command('deploy')
|
|
3231
|
+
.description('[ADMIN/LOCAL] Deploy microservice to Cloud Run (uses deploy-service-env.sh; powch re-provisions platform NEG only). Public MCP deploy is coming soon; this real deploy is Admin/local-only.')
|
|
3232
|
+
.option('-c, --community <id>', 'Community ID (auto-detects from context)')
|
|
3233
|
+
.option('-a, --app <id>', 'App ID (auto-detects from context)')
|
|
3234
|
+
.option('--env <env>', 'Target environment: dev|demo|prod')
|
|
3235
|
+
.option('--dry-run', 'Print deploy plan without executing gcloud')
|
|
3236
|
+
.option('--skip-register', 'Skip manifest registration after deploy (service may self-register on boot)')
|
|
3237
|
+
.action(async (options, command) => {
|
|
3238
|
+
try {
|
|
3239
|
+
const apiClient = new DeSciXApiClient();
|
|
3240
|
+
await requireAuth(apiClient);
|
|
3241
|
+
|
|
3242
|
+
const workspaceConfig = await WorkspaceConfig.load();
|
|
3243
|
+
const ctx = workspaceConfig.resolveContextWithOptions(options);
|
|
3244
|
+
const appId = ctx.appId;
|
|
3245
|
+
|
|
3246
|
+
if (!appId) {
|
|
3247
|
+
console.error(chalk.red('\n❌ App ID required.'));
|
|
3248
|
+
console.log(chalk.gray(' cd into an app directory, or use -a flag\n'));
|
|
3249
|
+
process.exit(1);
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
const parentEnv = command?.parent?.opts?.()?.env;
|
|
3253
|
+
const deployEnv = (options.env || parentEnv || workspaceConfig.env?.environment)?.toLowerCase();
|
|
3254
|
+
if (!deployEnv || !['dev', 'demo', 'prod'].includes(deployEnv)) {
|
|
3255
|
+
console.error(chalk.red('\n❌ --env is required (dev|demo|prod).'));
|
|
3256
|
+
console.log(chalk.gray(' Example: descix --env demo microservice deploy -a powch\n'));
|
|
3257
|
+
process.exit(1);
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
const workspaceRoot = workspaceConfig.getWorkspaceRoot();
|
|
3261
|
+
if (!workspaceRoot) {
|
|
3262
|
+
throw new Error('Could not resolve workspace root (.descix/workspace.json not found)');
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
const deployScript = path.join(
|
|
3266
|
+
workspaceRoot,
|
|
3267
|
+
'DeSciX/DeSciX_Cloud/microservice/admin/scripts/deploy/deploy-service-env.sh'
|
|
3268
|
+
);
|
|
3269
|
+
|
|
3270
|
+
try {
|
|
3271
|
+
await fs.access(deployScript);
|
|
3272
|
+
} catch {
|
|
3273
|
+
throw new Error(`Deploy script not found: ${deployScript}`);
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
console.log(chalk.cyan(`\n🚀 Deploying microservice: ${appId} (${deployEnv})\n`));
|
|
3277
|
+
if (options.dryRun) {
|
|
3278
|
+
console.log(chalk.yellow(' Dry run — gcloud deploy will not execute\n'));
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
const { spawnSync } = await import('child_process');
|
|
3282
|
+
const result = spawnSync('bash', [deployScript, appId, deployEnv], {
|
|
3283
|
+
cwd: path.dirname(deployScript),
|
|
3284
|
+
env: {
|
|
3285
|
+
...process.env,
|
|
3286
|
+
ECHO_MODE: options.dryRun ? 'true' : 'false'
|
|
3287
|
+
},
|
|
3288
|
+
stdio: 'inherit'
|
|
3289
|
+
});
|
|
3290
|
+
|
|
3291
|
+
if (result.status !== 0) {
|
|
3292
|
+
process.exit(result.status || 1);
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
if (options.skipRegister) {
|
|
3296
|
+
console.log(chalk.gray('\n Skipped manifest registration (--skip-register).\n'));
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
const microserviceDir = workspaceConfig.getMicroservicePath(appId);
|
|
3301
|
+
if (!microserviceDir) {
|
|
3302
|
+
console.log(chalk.yellow('\n⚠️ Deploy complete. Run descix microservice register to register manifest.\n'));
|
|
3303
|
+
return;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
const manifestPath = path.join(microserviceDir, 'manifest.json');
|
|
3307
|
+
try {
|
|
3308
|
+
await fs.access(manifestPath);
|
|
3309
|
+
} catch {
|
|
3310
|
+
console.log(chalk.yellow('\n⚠️ Deploy complete. No manifest.json — run descix microservice register when ready.\n'));
|
|
3311
|
+
return;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
console.log(chalk.cyan('\n📦 Registering microservice manifest...\n'));
|
|
3315
|
+
const registerArgs = [
|
|
3316
|
+
process.argv[1],
|
|
3317
|
+
'microservice',
|
|
3318
|
+
'register',
|
|
3319
|
+
'-m', manifestPath,
|
|
3320
|
+
'-a', appId
|
|
3321
|
+
];
|
|
3322
|
+
if (ctx.communityId) {
|
|
3323
|
+
registerArgs.push('-c', ctx.communityId);
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
const registerResult = spawnSync(process.execPath, registerArgs, {
|
|
3327
|
+
cwd: process.cwd(),
|
|
3328
|
+
stdio: 'inherit',
|
|
3329
|
+
env: process.env
|
|
3330
|
+
});
|
|
3331
|
+
|
|
3332
|
+
if (registerResult.status !== 0) {
|
|
3333
|
+
console.log(chalk.yellow('\n⚠️ Cloud Run deploy succeeded but manifest registration failed.'));
|
|
3334
|
+
console.log(chalk.gray(` Retry: descix microservice register -m ${manifestPath} -a ${appId}\n`));
|
|
3335
|
+
process.exit(registerResult.status || 1);
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
console.log(chalk.green('\n✅ Microservice deploy complete (Cloud Run + manifest registration).\n'));
|
|
3339
|
+
} catch (error) {
|
|
3340
|
+
console.error(chalk.red(`\n❌ Deploy failed: ${error.message}\n`));
|
|
3341
|
+
process.exit(1);
|
|
3342
|
+
}
|
|
3343
|
+
});
|
|
3344
|
+
|
|
2297
3345
|
// microservice register - Register with gateway
|
|
2298
3346
|
microserviceCommand
|
|
2299
3347
|
.command('register')
|
|
2300
|
-
.description('Register microservice with gateway (
|
|
3348
|
+
.description('Register microservice with gateway (-r <local SERVICE_README>; git-mode only — no Drive)')
|
|
2301
3349
|
.option('-m, --manifest <path>', 'Path to manifest.json', './manifest.json')
|
|
2302
3350
|
.option('-r, --readme <path>', 'Path to local SERVICE_README file')
|
|
2303
3351
|
.option('-c, --community <id>', 'Community ID (auto-detects from context)')
|
|
@@ -2366,63 +3414,23 @@ microserviceCommand
|
|
|
2366
3414
|
console.log(chalk.gray(` App: ${appId}`));
|
|
2367
3415
|
console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}\n`));
|
|
2368
3416
|
|
|
2369
|
-
// Option 1: Local README file provided via --readme
|
|
2370
|
-
if (options.readme) {
|
|
2371
|
-
const readmePath = path.resolve(options.readme);
|
|
2372
|
-
console.log(chalk.cyan(`📄 Reading local README: ${readmePath}\n`));
|
|
2373
|
-
|
|
2374
|
-
let readmeContent;
|
|
2375
|
-
try {
|
|
2376
|
-
readmeContent = await fs.readFile(readmePath, 'utf-8');
|
|
2377
|
-
} catch (error) {
|
|
2378
|
-
if (error.code === 'ENOENT') {
|
|
2379
|
-
console.error(chalk.red(`❌ README file not found: ${readmePath}`));
|
|
2380
|
-
process.exit(1);
|
|
2381
|
-
}
|
|
2382
|
-
throw new Error(`Failed to read README: ${error.message}`);
|
|
2383
|
-
}
|
|
2384
|
-
|
|
2385
|
-
// Register with local README content for vectorization
|
|
2386
|
-
const response = await apiClient.invoke('register_service', {
|
|
2387
|
-
manifest,
|
|
2388
|
-
readme_content: readmeContent
|
|
2389
|
-
});
|
|
2390
|
-
|
|
2391
|
-
console.log(chalk.green('✅ Microservice registered successfully!\n'));
|
|
2392
|
-
console.log(chalk.cyan(` Service: ${serviceName}`));
|
|
2393
|
-
console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}`));
|
|
2394
|
-
console.log(chalk.gray(` README vectorized for tell_me_how discovery\n`));
|
|
2395
|
-
|
|
2396
|
-
// Option 2: Check for SERVICE_README in Drive folder
|
|
2397
|
-
} else if (!options.skipReadmeCheck) {
|
|
2398
|
-
console.log(chalk.cyan(`🔍 Checking for ${readmeFileName} in app Drive folder...\n`));
|
|
2399
|
-
|
|
2400
|
-
const checkResponse = await apiClient.invoke('check_service_readme_exists', {
|
|
2401
|
-
community_id: communityId,
|
|
2402
|
-
app_id: appId,
|
|
2403
|
-
service_name: serviceName
|
|
2404
|
-
});
|
|
2405
|
-
|
|
2406
|
-
const readmeExists = checkResponse.message?.exists || checkResponse.exists || false;
|
|
2407
|
-
|
|
2408
|
-
if (!readmeExists) {
|
|
2409
|
-
// Output AI-friendly instructions for creating the README
|
|
2410
|
-
outputServiceReadmeInstructions(serviceName, appId, communityId, manifest);
|
|
2411
|
-
process.exit(1);
|
|
2412
|
-
}
|
|
2413
|
-
|
|
2414
|
-
console.log(chalk.green(`✅ Found ${readmeFileName}\n`));
|
|
2415
|
-
|
|
2416
|
-
// Read README content from Drive
|
|
2417
|
-
const readmeResponse = await apiClient.invoke('get_service_readme_content', {
|
|
2418
|
-
community_id: communityId,
|
|
2419
|
-
app_id: appId,
|
|
2420
|
-
service_name: serviceName
|
|
2421
|
-
});
|
|
3417
|
+
// Option 1: Local README file provided via --readme
|
|
3418
|
+
if (options.readme) {
|
|
3419
|
+
const readmePath = path.resolve(options.readme);
|
|
3420
|
+
console.log(chalk.cyan(`📄 Reading local README: ${readmePath}\n`));
|
|
2422
3421
|
|
|
2423
|
-
|
|
3422
|
+
let readmeContent;
|
|
3423
|
+
try {
|
|
3424
|
+
readmeContent = await fs.readFile(readmePath, 'utf-8');
|
|
3425
|
+
} catch (error) {
|
|
3426
|
+
if (error.code === 'ENOENT') {
|
|
3427
|
+
console.error(chalk.red(`❌ README file not found: ${readmePath}`));
|
|
3428
|
+
process.exit(1);
|
|
3429
|
+
}
|
|
3430
|
+
throw new Error(`Failed to read README: ${error.message}`);
|
|
3431
|
+
}
|
|
2424
3432
|
|
|
2425
|
-
// Register with README content for vectorization
|
|
3433
|
+
// Register with local README content for vectorization
|
|
2426
3434
|
const response = await apiClient.invoke('register_service', {
|
|
2427
3435
|
manifest,
|
|
2428
3436
|
readme_content: readmeContent
|
|
@@ -2433,9 +3441,11 @@ microserviceCommand
|
|
|
2433
3441
|
console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}`));
|
|
2434
3442
|
console.log(chalk.gray(` README vectorized for tell_me_how discovery\n`));
|
|
2435
3443
|
|
|
2436
|
-
// Option
|
|
2437
|
-
|
|
2438
|
-
|
|
3444
|
+
// Option 2: Explicit skip — register without README vectorization (git-mode only).
|
|
3445
|
+
// (Drive-folder README fallback removed per CEO-D-2026-06-01-MESH-AUTH-DRIVE-REMOVAL:
|
|
3446
|
+
// git corpus sync is the only canonical KB sync; -r <local README> is the only
|
|
3447
|
+
// README source. Nothing reads SERVICE_README from Drive anymore.)
|
|
3448
|
+
} else if (options.skipReadmeCheck) {
|
|
2439
3449
|
console.log(chalk.yellow('⚠️ Skipping README check (tool discovery will be limited)\n'));
|
|
2440
3450
|
|
|
2441
3451
|
const response = await apiClient.invoke('register_service', { manifest });
|
|
@@ -2444,6 +3454,11 @@ microserviceCommand
|
|
|
2444
3454
|
console.log(chalk.cyan(` Service: ${serviceName}`));
|
|
2445
3455
|
console.log(chalk.gray(` Commands: ${Object.keys(manifest.commands || {}).length}`));
|
|
2446
3456
|
console.log(chalk.yellow(` ⚠️ No README - service won't appear in tell_me_how results\n`));
|
|
3457
|
+
|
|
3458
|
+
// No -r and no --skip-readme-check: instruct the LOCAL README path (never Drive).
|
|
3459
|
+
} else {
|
|
3460
|
+
outputServiceReadmeInstructions(serviceName, appId, communityId, manifest);
|
|
3461
|
+
process.exit(1);
|
|
2447
3462
|
}
|
|
2448
3463
|
|
|
2449
3464
|
} catch (error) {
|
|
@@ -2484,8 +3499,8 @@ microserviceCommand
|
|
|
2484
3499
|
await fs.access(manifestPath);
|
|
2485
3500
|
} catch {
|
|
2486
3501
|
// If not in current directory, try to find the microservice path for detected context
|
|
2487
|
-
if (ctx.
|
|
2488
|
-
const appConfig = workspaceConfig.
|
|
3502
|
+
if (ctx.appId && workspaceConfig.workspaceRoot) {
|
|
3503
|
+
const appConfig = workspaceConfig.getAppByAppId(ctx.appId);
|
|
2489
3504
|
if (appConfig) {
|
|
2490
3505
|
const microservicePath = path.join(
|
|
2491
3506
|
workspaceConfig.workspaceRoot,
|
|
@@ -2694,7 +3709,7 @@ microserviceCommand
|
|
|
2694
3709
|
// microservice register-delegate - Register delegate key
|
|
2695
3710
|
microserviceCommand
|
|
2696
3711
|
.command('register-delegate')
|
|
2697
|
-
.description('
|
|
3712
|
+
.description('Provision the service delegate key (SERVICE_KEY) that authenticates mesh/loopback calls — run this if your service gets HTTP 401 calling /apifront or another service')
|
|
2698
3713
|
.option('-c, --community <id>', 'Community ID (auto-detects from context)')
|
|
2699
3714
|
.option('-a, --app <id>', 'App ID (auto-detects from context)')
|
|
2700
3715
|
.option('-s, --slot <id>', 'Service slot ID (uses first available if not provided)')
|
|
@@ -2733,20 +3748,28 @@ microserviceCommand
|
|
|
2733
3748
|
// Fetch entitlements
|
|
2734
3749
|
const entitlementsResponse = await apiClient.invoke('fetch_my_purchases', {});
|
|
2735
3750
|
const entitlements = entitlementsResponse.message || {};
|
|
2736
|
-
const
|
|
2737
|
-
|
|
3751
|
+
const allSlots = entitlements.service_slots || [];
|
|
3752
|
+
|
|
3753
|
+
// Service slots come ONLY from subscriptions right now. NFT-based slots are FUTURE
|
|
3754
|
+
// functionality (app/NFT association is not wired yet) — never select them here.
|
|
3755
|
+
// CEO-D-2026-06-01-MESH-AUTH-DRIVE-REMOVAL (Fix C).
|
|
3756
|
+
const serviceSlots = allSlots.filter(slot => slot.type === 'subscription');
|
|
3757
|
+
|
|
2738
3758
|
if (serviceSlots.length === 0) {
|
|
2739
|
-
console.error(chalk.red('❌ No service
|
|
2740
|
-
console.log(chalk.white('
|
|
3759
|
+
console.error(chalk.red('❌ No subscription service slot available.'));
|
|
3760
|
+
console.log(chalk.white(' Service slots are provided by a subscription. A subscription is required to'));
|
|
3761
|
+
console.log(chalk.white(' provision a delegate. (NFT-based slots are future functionality and are'));
|
|
3762
|
+
console.log(chalk.white(' not selectable yet.)'));
|
|
2741
3763
|
process.exit(1);
|
|
2742
3764
|
}
|
|
2743
|
-
|
|
2744
|
-
// Select slot
|
|
3765
|
+
|
|
3766
|
+
// Select slot (subscription slots only)
|
|
2745
3767
|
let selectedSlot = serviceSlots[0];
|
|
2746
3768
|
if (options.slot) {
|
|
2747
|
-
selectedSlot = serviceSlots.find(s => (s.
|
|
3769
|
+
selectedSlot = serviceSlots.find(s => (s.id || s.nft_id) === options.slot);
|
|
2748
3770
|
if (!selectedSlot) {
|
|
2749
|
-
console.error(chalk.red(`❌
|
|
3771
|
+
console.error(chalk.red(`❌ Subscription slot ${options.slot} not found in your entitlements.`));
|
|
3772
|
+
console.log(chalk.gray(' Only subscription slots are selectable. Run without -s to use the first available.'));
|
|
2750
3773
|
process.exit(1);
|
|
2751
3774
|
}
|
|
2752
3775
|
}
|
|
@@ -2805,6 +3828,27 @@ microserviceCommand
|
|
|
2805
3828
|
}
|
|
2806
3829
|
});
|
|
2807
3830
|
|
|
3831
|
+
// microservice restart - Kill + relaunch a local microservice (DEV only)
|
|
3832
|
+
microserviceCommand
|
|
3833
|
+
.command('restart <name>')
|
|
3834
|
+
.description('Restart a local microservice (DEV only; DEMO/PROD use deploy scripts)')
|
|
3835
|
+
.option('--env <env>', 'Target environment: dev | demo | prod', 'dev')
|
|
3836
|
+
.action(async (name, options) => {
|
|
3837
|
+
try {
|
|
3838
|
+
const { restartMicroservice } = await import('../lib/commands/microservice-restart.js');
|
|
3839
|
+
const { runHealth } = await import('../lib/commands/health.js');
|
|
3840
|
+
await restartMicroservice({
|
|
3841
|
+
name,
|
|
3842
|
+
env: options.env,
|
|
3843
|
+
deps: { runHealth }
|
|
3844
|
+
});
|
|
3845
|
+
console.log();
|
|
3846
|
+
} catch (error) {
|
|
3847
|
+
console.error(chalk.red(`\n${error.message}\n`));
|
|
3848
|
+
process.exit(1);
|
|
3849
|
+
}
|
|
3850
|
+
});
|
|
3851
|
+
|
|
2808
3852
|
// ============ Role Commands ============
|
|
2809
3853
|
|
|
2810
3854
|
const roleCommand = program
|
|
@@ -2950,131 +3994,225 @@ repCommand
|
|
|
2950
3994
|
}
|
|
2951
3995
|
});
|
|
2952
3996
|
|
|
2953
|
-
// ============ Chat Session Management ============
|
|
2954
|
-
|
|
2955
|
-
/**
|
|
2956
|
-
* Get interaction_id from session file
|
|
2957
|
-
* @param {string} communityId
|
|
2958
|
-
* @param {string} appId
|
|
2959
|
-
* @returns {Promise<string|null>}
|
|
2960
|
-
*/
|
|
2961
|
-
async function getSessionInteractionId(communityId, appId) {
|
|
2962
|
-
const sessionPath = path.join(os.homedir(), '.descix', 'sessions', `${communityId}_${appId}.json`);
|
|
2963
|
-
try {
|
|
2964
|
-
const data = JSON.parse(await fs.readFile(sessionPath, 'utf-8'));
|
|
2965
|
-
return data.interaction_id || null;
|
|
2966
|
-
} catch {
|
|
2967
|
-
return null;
|
|
2968
|
-
}
|
|
2969
|
-
}
|
|
2970
|
-
|
|
2971
|
-
/**
|
|
2972
|
-
* Save interaction_id to session file
|
|
2973
|
-
* @param {string} communityId
|
|
2974
|
-
* @param {string} appId
|
|
2975
|
-
* @param {string} interactionId
|
|
2976
|
-
*/
|
|
2977
|
-
async function saveSessionInteractionId(communityId, appId, interactionId) {
|
|
2978
|
-
const sessionDir = path.join(os.homedir(), '.descix', 'sessions');
|
|
2979
|
-
await fs.mkdir(sessionDir, { recursive: true });
|
|
2980
|
-
const sessionPath = path.join(sessionDir, `${communityId}_${appId}.json`);
|
|
2981
|
-
await fs.writeFile(sessionPath, JSON.stringify({
|
|
2982
|
-
interaction_id: interactionId,
|
|
2983
|
-
updated: Date.now()
|
|
2984
|
-
}, null, 2));
|
|
2985
|
-
}
|
|
2986
|
-
|
|
2987
|
-
/**
|
|
2988
|
-
* Clear session file
|
|
2989
|
-
* @param {string} communityId
|
|
2990
|
-
* @param {string} appId
|
|
2991
|
-
*/
|
|
2992
|
-
async function clearSession(communityId, appId) {
|
|
2993
|
-
const sessionPath = path.join(os.homedir(), '.descix', 'sessions', `${communityId}_${appId}.json`);
|
|
2994
|
-
try {
|
|
2995
|
-
await fs.unlink(sessionPath);
|
|
2996
|
-
} catch {
|
|
2997
|
-
// File doesn't exist, that's fine
|
|
2998
|
-
}
|
|
2999
|
-
}
|
|
3000
|
-
|
|
3001
3997
|
// ============ Chat Commands ============
|
|
3002
3998
|
|
|
3003
3999
|
program
|
|
3004
4000
|
.command('chat [question...]')
|
|
3005
4001
|
.description('Chat with an app agent. Usage: descix chat "Your question" or descix chat -q "Your question"')
|
|
3006
|
-
.option('-c, --community <id>', 'Community ID (
|
|
3007
|
-
.option('-a, --app <id>', 'App ID (
|
|
4002
|
+
.option('-c, --community <id>', 'Community ID (optional; server resolves it from Products)')
|
|
4003
|
+
.option('-a, --app <id>', 'App ID (required unless run from inside a mapped app directory)')
|
|
3008
4004
|
.option('-q, --question <text>', 'Question to ask (alternative to positional argument)')
|
|
3009
|
-
.option('-k, --kb <id
|
|
4005
|
+
.option('-k, --kb <id...>', 'Knowledge Base ID(s) — repeat for multi-KB, use * for all')
|
|
4006
|
+
.option('--apps <ids>', 'Comma-separated app IDs for cross-app query')
|
|
4007
|
+
.option('--level <n>', 'Intelligence level (1-5)', parseInt)
|
|
4008
|
+
.option('--model <name>', 'Explicit model override')
|
|
4009
|
+
.option('--thinking <budget>', 'Thinking token budget (-1=dynamic, 0=off, N=fixed)', parseInt)
|
|
4010
|
+
.option('--tokens', 'Show token usage in response footer')
|
|
3010
4011
|
.option('--new', 'Start a new conversation (clear session)')
|
|
3011
4012
|
.action(async (questionArgs, options) => {
|
|
3012
4013
|
try {
|
|
3013
4014
|
const apiClient = new DeSciXApiClient();
|
|
3014
4015
|
await requireAuth(apiClient);
|
|
3015
|
-
|
|
4016
|
+
|
|
3016
4017
|
// Get question from positional args or -q option
|
|
3017
4018
|
const question = options.question || (questionArgs && questionArgs.join(' '));
|
|
3018
4019
|
if (!question) {
|
|
3019
4020
|
console.error(chalk.red('Error: Question required. Usage: descix chat "Your question" or descix chat -q "Your question"'));
|
|
3020
4021
|
process.exit(1);
|
|
3021
4022
|
}
|
|
3022
|
-
|
|
3023
|
-
//
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
4023
|
+
|
|
4024
|
+
// Multi-app mode: --apps daita,unk-beast
|
|
4025
|
+
if (options.apps) {
|
|
4026
|
+
const appIds = options.apps.split(',').map(s => s.trim());
|
|
4027
|
+
const apps = appIds.map(id => {
|
|
4028
|
+
const kbNames = options.kb && !options.kb.includes('General') ? options.kb : undefined;
|
|
4029
|
+
return kbNames ? { app_id: id, knowledgebase_names: kbNames } : { app_id: id };
|
|
4030
|
+
});
|
|
4031
|
+
|
|
4032
|
+
console.log(chalk.gray(`Asking ${appIds.join(', ')}...`));
|
|
4033
|
+
|
|
4034
|
+
const response = await apiClient.invoke('ask_multiple_apps', {
|
|
4035
|
+
apps,
|
|
4036
|
+
user_input: question,
|
|
4037
|
+
intelligence_level: options.level,
|
|
4038
|
+
model: options.model,
|
|
4039
|
+
thinking_budget: options.thinking,
|
|
4040
|
+
streaming: false
|
|
4041
|
+
});
|
|
4042
|
+
const result = response.message || response;
|
|
4043
|
+
|
|
4044
|
+
console.log(chalk.green('\n\u2705 Response:\n'));
|
|
4045
|
+
console.log(chalk.white(result.response || result.text || JSON.stringify(result, null, 2)));
|
|
4046
|
+
|
|
4047
|
+
if (result.apps_queried) {
|
|
4048
|
+
console.log(chalk.cyan(`\nApps queried: ${result.apps_queried.join(', ')}`));
|
|
4049
|
+
}
|
|
4050
|
+
|
|
4051
|
+
const sources = result.sources || [];
|
|
4052
|
+
if (sources.length > 0) {
|
|
4053
|
+
console.log(chalk.cyan('\n\ud83d\udcda Sources:'));
|
|
4054
|
+
sources.forEach((src, i) => {
|
|
4055
|
+
const fileName = src.fileName || src.file_path || src.source || src;
|
|
4056
|
+
const fileId = src.fileId || src.id || '';
|
|
4057
|
+
const score = src.score || src.similarity || 0;
|
|
4058
|
+
const scoreStr = score ? ` (score: ${score.toFixed(3)})` : '';
|
|
4059
|
+
const idStr = fileId ? ` [ID: ${fileId}]` : '';
|
|
4060
|
+
console.log(chalk.gray(` ${i + 1}. ${fileName}${idStr}${scoreStr}`));
|
|
4061
|
+
});
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
// Token usage footer
|
|
4065
|
+
if (options.tokens && result.usage) {
|
|
4066
|
+
const u = result.usage;
|
|
4067
|
+
const thinkStr = u.thinking_tokens ? ` + ${u.thinking_tokens} thinking` : '';
|
|
4068
|
+
console.log(chalk.gray(`\n[${result.model_used || 'unknown'} | ${u.input_tokens} in + ${u.output_tokens} out${thinkStr} = ${u.total_tokens} tokens]`));
|
|
4069
|
+
}
|
|
4070
|
+
console.log('');
|
|
4071
|
+
return;
|
|
4072
|
+
}
|
|
4073
|
+
|
|
4074
|
+
// Single-app mode.
|
|
4075
|
+
// WS-R7-PREREQS (CEO-D-2026-07-04-R7-PREREQS-RESCOPE ruling 3): the hardcoded
|
|
4076
|
+
// communityId||'descix' / appId||'agent' fallbacks are REMOVED. app_id must be
|
|
4077
|
+
// resolvable (flag or workspace context) — FAIL LOUD otherwise. community_id is
|
|
4078
|
+
// server-authoritative: the response carries the Products-resolved value and the
|
|
4079
|
+
// session file is keyed from it.
|
|
4080
|
+
let communityId = options.community || null;
|
|
4081
|
+
let appId = options.app || null;
|
|
4082
|
+
|
|
3027
4083
|
if (!communityId || !appId) {
|
|
3028
4084
|
try {
|
|
3029
|
-
const
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
const detected = ctx.detectContext();
|
|
4085
|
+
const wsConfig = await WorkspaceConfig.tryLoad();
|
|
4086
|
+
if (wsConfig) {
|
|
4087
|
+
const detected = wsConfig.detectContext();
|
|
3033
4088
|
if (detected) {
|
|
3034
|
-
communityId = communityId || detected.communityId;
|
|
3035
|
-
appId = appId || detected.appId;
|
|
4089
|
+
communityId = communityId || detected.communityId || null;
|
|
4090
|
+
appId = appId || detected.appId || null;
|
|
3036
4091
|
}
|
|
3037
4092
|
}
|
|
3038
4093
|
} catch {
|
|
3039
|
-
//
|
|
4094
|
+
// No workspace context — resolution below decides, no defaults
|
|
3040
4095
|
}
|
|
3041
4096
|
}
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
4097
|
+
|
|
4098
|
+
if (!appId) {
|
|
4099
|
+
console.error(chalk.red(
|
|
4100
|
+
'Error: no app resolved. Pass -a/--app <app_id> or run from inside an app directory ' +
|
|
4101
|
+
'mapped in .descix/workspace.json. (The legacy hardcoded descix/agent fallback was ' +
|
|
4102
|
+
'removed — it silently mis-keyed sessions.)'
|
|
4103
|
+
));
|
|
4104
|
+
process.exit(1);
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
// Session continuity: exact key when the community is known client-side; otherwise
|
|
4108
|
+
// the app's newest session file (app_id is globally unique) carries the thread.
|
|
3048
4109
|
let previousInteractionId = null;
|
|
3049
4110
|
if (!options.new) {
|
|
3050
|
-
|
|
4111
|
+
if (communityId) {
|
|
4112
|
+
previousInteractionId = await getSessionInteractionId(communityId, appId);
|
|
4113
|
+
} else {
|
|
4114
|
+
const sessions = await findSessionsForApp(appId);
|
|
4115
|
+
if (sessions.length > 0) {
|
|
4116
|
+
previousInteractionId = await getSessionInteractionId(sessions[0].communityId, appId);
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
3051
4119
|
} else {
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
4120
|
+
await clearAppSessions(communityId, appId);
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
// Resolve KB param — single or multi.
|
|
4124
|
+
//
|
|
4125
|
+
// ws-mcp-surface-basics — ONE NORMALIZATION OWNER. `descix chat` and the
|
|
4126
|
+
// ask_question_to_app MCP tool are not two backends: both invoke the SAME
|
|
4127
|
+
// ask_question_to_app command over /apifront/. The only thing that had diverged was the
|
|
4128
|
+
// CLIENT-SIDE default — the CLI substituted a hardcoded 'General' while MCP callers sent
|
|
4129
|
+
// nothing and let the server decide. That hardcoded fallback (anti-pattern #7) is why the
|
|
4130
|
+
// CLI reported "Default KB not found" against apps whose KBs are not named 'General': the
|
|
4131
|
+
// CLI asserted a KB the app never had. When the caller names no KB we now send NO kb param
|
|
4132
|
+
// and the server's resolver (pineconeService.resolveKbNameScope, fed by
|
|
4133
|
+
// utils.DEFAULT_KNOWLEDGEBASE_NAME) is the sole owner of what "default" means.
|
|
4134
|
+
const kbList = options.kb || null;
|
|
4135
|
+
const useMultiKb = !!kbList && (kbList.length > 1 || kbList.includes('*'));
|
|
4136
|
+
|
|
4137
|
+
console.log(chalk.gray(`Asking ${communityId ? communityId + '/' : ''}${appId}...`));
|
|
4138
|
+
|
|
4139
|
+
const invokeParams = {
|
|
3059
4140
|
app_id: appId,
|
|
3060
|
-
knowledgebase_name: options.kb,
|
|
3061
4141
|
user_input: question,
|
|
3062
4142
|
previous_interaction_id: previousInteractionId,
|
|
3063
|
-
streaming: false
|
|
3064
|
-
|
|
4143
|
+
streaming: false,
|
|
4144
|
+
intelligence_level: options.level,
|
|
4145
|
+
model: options.model,
|
|
4146
|
+
thinking_budget: options.thinking,
|
|
4147
|
+
};
|
|
4148
|
+
|
|
4149
|
+
if (useMultiKb) {
|
|
4150
|
+
invokeParams.knowledgebase_names = kbList;
|
|
4151
|
+
} else if (kbList) {
|
|
4152
|
+
invokeParams.knowledgebase_name = kbList[0];
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
// Stale-thread self-heal. Exactly ONE retry, and only for the typed 400 — see
|
|
4156
|
+
// isStaleThreadError and the asymmetry note above it. Anything else (including the 403
|
|
4157
|
+
// "this thread is not yours") propagates untouched to the catch below, which prints it
|
|
4158
|
+
// and exits non-zero.
|
|
4159
|
+
let response;
|
|
4160
|
+
let healedFromInteractionId = null;
|
|
4161
|
+
try {
|
|
4162
|
+
response = await apiClient.invoke('ask_question_to_app', invokeParams);
|
|
4163
|
+
} catch (error) {
|
|
4164
|
+
if (!isStaleThreadError(error, previousInteractionId)) throw error;
|
|
4165
|
+
|
|
4166
|
+
healedFromInteractionId = previousInteractionId;
|
|
4167
|
+
await clearAppSessions(communityId, appId);
|
|
4168
|
+
|
|
4169
|
+
// Single retry with no thread — byte-identical to what `--new` would have sent.
|
|
4170
|
+
// NO loop: if this one fails, its error propagates.
|
|
4171
|
+
previousInteractionId = null;
|
|
4172
|
+
invokeParams.previous_interaction_id = null;
|
|
4173
|
+
response = await apiClient.invoke('ask_question_to_app', invokeParams);
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
// Notice goes to STDERR, deliberately. stdout stays exactly the answer (and stays
|
|
4177
|
+
// valid JSON for any `--json`-style consumer), while a human at a terminal always sees
|
|
4178
|
+
// it and a `| jq` pipeline can never swallow it. The user must never be left believing
|
|
4179
|
+
// continuity survived when it did not.
|
|
4180
|
+
if (healedFromInteractionId) {
|
|
4181
|
+
console.error(chalk.yellow(
|
|
4182
|
+
`Note: the previous conversation thread for ${appId} was no longer available ` +
|
|
4183
|
+
`(expired or retired) — it has been discarded and a NEW thread was started. ` +
|
|
4184
|
+
`This answer does not carry any earlier context.`
|
|
4185
|
+
));
|
|
4186
|
+
}
|
|
4187
|
+
|
|
3065
4188
|
const result = response.message || response;
|
|
3066
|
-
|
|
3067
|
-
// Save new interaction_id for next message
|
|
4189
|
+
|
|
4190
|
+
// Save new interaction_id for next message, keyed by the SERVER-resolved
|
|
4191
|
+
// community (authoritative — Products-hydrated). Client-resolved (-c flag /
|
|
4192
|
+
// workspace context) is only accepted when the server did not return one
|
|
4193
|
+
// (older backend); with NEITHER available this FAILS LOUD after printing the
|
|
4194
|
+
// response — the session must never be keyed under a guessed community.
|
|
3068
4195
|
if (result.interaction_id) {
|
|
3069
|
-
|
|
4196
|
+
const authoritativeCommunityId = result.community_id || communityId;
|
|
4197
|
+
if (!authoritativeCommunityId) {
|
|
4198
|
+
console.log(chalk.white(result.response || result.text || ''));
|
|
4199
|
+
console.error(chalk.red(
|
|
4200
|
+
'\nError: cannot key the chat session — the server did not return community_id ' +
|
|
4201
|
+
'and none was resolvable client-side. The response above was NOT session-saved. ' +
|
|
4202
|
+
'Pass -c/--community, or update the backend (ask_question_to_app must return the ' +
|
|
4203
|
+
'Products-resolved community_id per CEO-D-2026-07-04-R7-PREREQS-RESCOPE).'
|
|
4204
|
+
));
|
|
4205
|
+
process.exit(1);
|
|
4206
|
+
}
|
|
4207
|
+
await saveSessionAuthoritative(authoritativeCommunityId, appId, result.interaction_id);
|
|
3070
4208
|
}
|
|
3071
|
-
|
|
3072
|
-
console.log(chalk.green('\n
|
|
4209
|
+
|
|
4210
|
+
console.log(chalk.green('\n\u2705 Response:\n'));
|
|
3073
4211
|
console.log(chalk.white(result.response || result.text || JSON.stringify(result, null, 2)));
|
|
3074
|
-
|
|
4212
|
+
|
|
3075
4213
|
const sources = result.sources || response.sources || response.message?.sources || [];
|
|
3076
4214
|
if (sources && sources.length > 0) {
|
|
3077
|
-
console.log(chalk.cyan('\n
|
|
4215
|
+
console.log(chalk.cyan('\n\ud83d\udcda Sources:'));
|
|
3078
4216
|
sources.forEach((src, i) => {
|
|
3079
4217
|
const fileName = src.fileName || src.file_path || src.source || src;
|
|
3080
4218
|
const fileId = src.fileId || src.id || '';
|
|
@@ -3084,6 +4222,14 @@ program
|
|
|
3084
4222
|
console.log(chalk.gray(` ${i + 1}. ${fileName}${idStr}${scoreStr}`));
|
|
3085
4223
|
});
|
|
3086
4224
|
}
|
|
4225
|
+
|
|
4226
|
+
// Token usage footer
|
|
4227
|
+
if (options.tokens && result.usage) {
|
|
4228
|
+
const u = result.usage;
|
|
4229
|
+
const thinkStr = u.thinking_tokens ? ` + ${u.thinking_tokens} thinking` : '';
|
|
4230
|
+
const levelStr = options.level ? `level ${options.level}` : `level ${result.usage?.intelligence_level || '2'}`;
|
|
4231
|
+
console.log(chalk.gray(`\n[${result.model_used || 'unknown'} | ${levelStr} | ${u.input_tokens} in + ${u.output_tokens} out${thinkStr} = ${u.total_tokens} tokens]`));
|
|
4232
|
+
}
|
|
3087
4233
|
console.log('');
|
|
3088
4234
|
} catch (error) {
|
|
3089
4235
|
console.error(chalk.red(error.message));
|
|
@@ -3094,14 +4240,19 @@ program
|
|
|
3094
4240
|
program
|
|
3095
4241
|
.command('new-chat')
|
|
3096
4242
|
.description('Clear chat session for an app (start fresh conversation)')
|
|
3097
|
-
.option('-c, --community <id>', 'Community ID (
|
|
3098
|
-
.option('-a, --app <id>', 'App ID (
|
|
4243
|
+
.option('-c, --community <id>', 'Community ID (optional; clears only that community key)')
|
|
4244
|
+
.option('-a, --app <id>', 'App ID (required)')
|
|
3099
4245
|
.action(async (options) => {
|
|
3100
4246
|
try {
|
|
3101
|
-
|
|
3102
|
-
const appId = options.app ||
|
|
3103
|
-
|
|
3104
|
-
|
|
4247
|
+
// WS-R7-PREREQS: hardcoded descix/agent fallbacks REMOVED — an app id is required.
|
|
4248
|
+
const appId = options.app || null;
|
|
4249
|
+
if (!appId) {
|
|
4250
|
+
console.error(chalk.red('Error: -a/--app <app_id> is required (the legacy hardcoded descix/agent fallback was removed).'));
|
|
4251
|
+
process.exit(1);
|
|
4252
|
+
}
|
|
4253
|
+
const cleared = await clearAppSessions(options.community || null, appId);
|
|
4254
|
+
const scope = options.community ? `${options.community}/${appId}` : appId;
|
|
4255
|
+
console.log(chalk.green(`Chat session cleared for ${scope} (${cleared} session file(s)). Next chat will start fresh.`));
|
|
3105
4256
|
} catch (error) {
|
|
3106
4257
|
console.error(chalk.red(error.message));
|
|
3107
4258
|
process.exit(1);
|
|
@@ -3118,16 +4269,26 @@ program
|
|
|
3118
4269
|
const apiClient = new DeSciXApiClient();
|
|
3119
4270
|
await requireAuth(apiClient);
|
|
3120
4271
|
|
|
4272
|
+
// APP entitlements: use the product_type:'APP' fast-path. The no-filter call
|
|
4273
|
+
// returns the STORE BUNDLE (which has no top-level `apps` key — apps are nested
|
|
4274
|
+
// under each community), so `result.apps` was always undefined → "Apps (0)" even
|
|
4275
|
+
// when the user holds entitlements. The fast-path returns the user's actual
|
|
4276
|
+
// purchased App objects (userManagement.js fetch_my_purchases APP branch).
|
|
4277
|
+
const appResp = await apiClient.invoke('fetch_my_purchases', { product_type: 'APP' });
|
|
4278
|
+
const appResult = appResp.message || appResp;
|
|
4279
|
+
const apps = appResult.apps || [];
|
|
4280
|
+
|
|
4281
|
+
// COMMUNITY entitlements: the standard call exposes the user's owned community
|
|
4282
|
+
// IDs via `my_community_ids` (the top-level `communities` field is the store
|
|
4283
|
+
// catalog, not the user's overlay).
|
|
3121
4284
|
const response = await apiClient.invoke('fetch_my_purchases', {});
|
|
3122
4285
|
const result = response.message || response;
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
const apps = result.apps || [];
|
|
3126
|
-
|
|
4286
|
+
const communityIds = result.my_community_ids || [];
|
|
4287
|
+
|
|
3127
4288
|
console.log(chalk.green('\n✅ Your Purchases:\n'));
|
|
3128
|
-
console.log(chalk.cyan(`Communities (${
|
|
3129
|
-
|
|
3130
|
-
console.log(chalk.yellow(` ${idx + 1}. ${
|
|
4289
|
+
console.log(chalk.cyan(`Communities (${communityIds.length}):`));
|
|
4290
|
+
communityIds.forEach((cid, idx) => {
|
|
4291
|
+
console.log(chalk.yellow(` ${idx + 1}. ${cid}`));
|
|
3131
4292
|
});
|
|
3132
4293
|
console.log();
|
|
3133
4294
|
console.log(chalk.cyan(`Apps (${apps.length}):`));
|
|
@@ -3154,7 +4315,7 @@ configCommand
|
|
|
3154
4315
|
try {
|
|
3155
4316
|
await configCommands.show();
|
|
3156
4317
|
} catch (error) {
|
|
3157
|
-
|
|
4318
|
+
fail(error);
|
|
3158
4319
|
}
|
|
3159
4320
|
});
|
|
3160
4321
|
|
|
@@ -3167,143 +4328,87 @@ configCommand
|
|
|
3167
4328
|
try {
|
|
3168
4329
|
await configCommands.setUrl(url, options);
|
|
3169
4330
|
} catch (error) {
|
|
3170
|
-
|
|
4331
|
+
fail(error);
|
|
3171
4332
|
}
|
|
3172
4333
|
});
|
|
3173
4334
|
|
|
3174
4335
|
configCommand
|
|
3175
4336
|
.command('init')
|
|
3176
|
-
.description('Initialize configuration')
|
|
3177
|
-
|
|
4337
|
+
.description('Initialize configuration for an environment (requires --env)')
|
|
4338
|
+
// `--dev` DELETED, not deprecated: its absence meant PRODUCTION, so the most common
|
|
4339
|
+
// invocation (`descix config init`) wrote a prod origin nobody named. There is no default.
|
|
4340
|
+
.option('--env <name>', 'Environment to initialize: dev, demo or prod')
|
|
4341
|
+
.option('--url <url>', 'Explicit origin (for a self-hosted or port-forwarded gateway)')
|
|
3178
4342
|
.option('-g, --global', 'Save to global config (~/.descixrc)')
|
|
3179
4343
|
.action(async (options) => {
|
|
3180
4344
|
try {
|
|
3181
|
-
|
|
3182
|
-
await configCommands.init(env, options);
|
|
4345
|
+
await configCommands.init(options.env, options);
|
|
3183
4346
|
} catch (error) {
|
|
3184
|
-
|
|
4347
|
+
fail(error);
|
|
3185
4348
|
}
|
|
3186
4349
|
});
|
|
3187
4350
|
|
|
3188
4351
|
configCommand
|
|
3189
|
-
.command('set-
|
|
3190
|
-
.description('Set
|
|
3191
|
-
.argument('<
|
|
3192
|
-
.option('-
|
|
3193
|
-
.
|
|
3194
|
-
.action(async (mode, options) => {
|
|
4352
|
+
.command('set-env')
|
|
4353
|
+
.description('Set target environment persistently (updates workspace.json, auto-reconnects)')
|
|
4354
|
+
.argument('<env>', 'Environment: dev, demo, prod, or custom name')
|
|
4355
|
+
.option('-u, --url <url>', 'API URL override (for custom environments)')
|
|
4356
|
+
.action(async (env, options) => {
|
|
3195
4357
|
try {
|
|
3196
|
-
await configCommands.
|
|
4358
|
+
await configCommands.setEnv(env, options);
|
|
3197
4359
|
} catch (error) {
|
|
3198
|
-
|
|
3199
|
-
process.exit(1);
|
|
4360
|
+
fail(error);
|
|
3200
4361
|
}
|
|
3201
4362
|
});
|
|
3202
4363
|
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
.
|
|
3207
|
-
.
|
|
4364
|
+
configCommand
|
|
4365
|
+
.command('set-gateway-port')
|
|
4366
|
+
.description('Set the port `descix serve` listens on (env.gateway.port)')
|
|
4367
|
+
.argument('<port>', 'Port number 1-65535, or "none" to remove')
|
|
4368
|
+
.action(async (port) => {
|
|
4369
|
+
try {
|
|
4370
|
+
await configCommands.setGatewayPort(port === 'none' ? null : port);
|
|
4371
|
+
} catch (error) {
|
|
4372
|
+
fail(error);
|
|
4373
|
+
}
|
|
4374
|
+
});
|
|
3208
4375
|
|
|
3209
|
-
|
|
3210
|
-
.command('
|
|
3211
|
-
.description('
|
|
3212
|
-
.option('--
|
|
4376
|
+
configCommand
|
|
4377
|
+
.command('set-dev-certs')
|
|
4378
|
+
.description('Point local dev servers at your own TLS cert pair (env.devCerts) — the gateway AND every app behind it')
|
|
4379
|
+
.option('-d, --dir <path>', 'Directory holding cert.pem + key.pem')
|
|
4380
|
+
.option('-c, --cert <path>', 'Certificate file')
|
|
4381
|
+
.option('-k, --key <path>', 'Private key file')
|
|
4382
|
+
.option('--clear', 'Remove the setting and fall back to the SDK-tracked SAN pair')
|
|
3213
4383
|
.action(async (options) => {
|
|
3214
4384
|
try {
|
|
3215
|
-
|
|
3216
|
-
await requireAuth(apiClient);
|
|
3217
|
-
|
|
3218
|
-
const fs = await import('fs/promises');
|
|
3219
|
-
const path = await import('path');
|
|
3220
|
-
|
|
3221
|
-
const readmePath = path.resolve(options.path || './README');
|
|
3222
|
-
|
|
3223
|
-
console.log(chalk.cyan(`\n📚 Syncing documentation to platform-docs KB\n`));
|
|
3224
|
-
console.log(chalk.gray(`Reading from: ${readmePath}\n`));
|
|
3225
|
-
|
|
3226
|
-
// Check if README folder exists
|
|
3227
|
-
try {
|
|
3228
|
-
const stats = await fs.stat(readmePath);
|
|
3229
|
-
if (!stats.isDirectory()) {
|
|
3230
|
-
throw new Error(`Path is not a directory: ${readmePath}`);
|
|
3231
|
-
}
|
|
3232
|
-
} catch (error) {
|
|
3233
|
-
if (error.code === 'ENOENT') {
|
|
3234
|
-
throw new Error(`README folder not found: ${readmePath}`);
|
|
3235
|
-
}
|
|
3236
|
-
throw error;
|
|
3237
|
-
}
|
|
3238
|
-
|
|
3239
|
-
// Recursively read all .md files
|
|
3240
|
-
const files = [];
|
|
3241
|
-
async function readDir(dirPath, basePath = '') {
|
|
3242
|
-
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
3243
|
-
for (const entry of entries) {
|
|
3244
|
-
const fullPath = path.join(dirPath, entry.name);
|
|
3245
|
-
const relPath = path.join(basePath, entry.name);
|
|
3246
|
-
|
|
3247
|
-
if (entry.isDirectory()) {
|
|
3248
|
-
// Skip node_modules and .git
|
|
3249
|
-
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name.startsWith('.')) {
|
|
3250
|
-
continue;
|
|
3251
|
-
}
|
|
3252
|
-
await readDir(fullPath, relPath);
|
|
3253
|
-
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
3254
|
-
const content = await fs.readFile(fullPath, 'utf-8');
|
|
3255
|
-
files.push({
|
|
3256
|
-
name: entry.name,
|
|
3257
|
-
path: relPath,
|
|
3258
|
-
content: Buffer.from(content).toString('base64') // Base64 encode
|
|
3259
|
-
});
|
|
3260
|
-
}
|
|
3261
|
-
}
|
|
3262
|
-
}
|
|
3263
|
-
|
|
3264
|
-
await readDir(readmePath);
|
|
3265
|
-
|
|
3266
|
-
if (files.length === 0) {
|
|
3267
|
-
throw new Error('No .md files found in README folder');
|
|
3268
|
-
}
|
|
3269
|
-
|
|
3270
|
-
console.log(chalk.gray(`Found ${files.length} markdown files. Uploading...\n`));
|
|
3271
|
-
|
|
3272
|
-
// Upload files to platform-docs KB
|
|
3273
|
-
const response = await apiClient.invoke('upload_files_to_kb', {
|
|
3274
|
-
community_id: 'descix',
|
|
3275
|
-
app_id: 'platform-docs',
|
|
3276
|
-
kb_id: 'General',
|
|
3277
|
-
files: files
|
|
3278
|
-
});
|
|
3279
|
-
const result = response.message || response;
|
|
3280
|
-
|
|
3281
|
-
console.log(chalk.green(`\n✅ Uploaded ${result.uploaded || 0} files\n`));
|
|
3282
|
-
|
|
3283
|
-
// Sync to RAG
|
|
3284
|
-
console.log(chalk.gray('Syncing to RAG vectors...\n'));
|
|
3285
|
-
const syncResponse = await apiClient.invoke('sync_knowledge_base', {
|
|
3286
|
-
community_id: 'descix',
|
|
3287
|
-
app_id: 'platform-docs',
|
|
3288
|
-
kb_id: 'General'
|
|
3289
|
-
});
|
|
3290
|
-
const syncResult = syncResponse.message || syncResponse;
|
|
3291
|
-
|
|
3292
|
-
console.log(chalk.green('✅ Documentation synced to RAG!\n'));
|
|
3293
|
-
console.log(chalk.cyan(` Total Vectors: ${syncResult.vectorCount || result.totalVectors || 0}\n`));
|
|
3294
|
-
|
|
3295
|
-
if (result.errors && result.errors.length > 0) {
|
|
3296
|
-
console.log(chalk.yellow(' Errors:'));
|
|
3297
|
-
result.errors.forEach(err => {
|
|
3298
|
-
console.log(chalk.red(` - ${err.file}: ${err.error}`));
|
|
3299
|
-
});
|
|
3300
|
-
}
|
|
3301
|
-
|
|
3302
|
-
console.log(chalk.gray('\nQuery documentation with: descix rag query -c descix -a platform-docs -k General -q "your question"\n'));
|
|
3303
|
-
|
|
4385
|
+
await configCommands.setDevCerts(options);
|
|
3304
4386
|
} catch (error) {
|
|
3305
|
-
|
|
3306
|
-
|
|
4387
|
+
fail(error);
|
|
4388
|
+
}
|
|
4389
|
+
});
|
|
4390
|
+
|
|
4391
|
+
configCommand
|
|
4392
|
+
.command('set-powch-url')
|
|
4393
|
+
.description('Set where Powch lives (env.powchUrl) — its OWN origin; Powch is cross-origin from the shell by design')
|
|
4394
|
+
.argument('<url>', 'Powch origin (e.g. https://powch.dev.descix.net), or "none" to remove')
|
|
4395
|
+
.action(async (url) => {
|
|
4396
|
+
try {
|
|
4397
|
+
await configCommands.setPowchUrl(url === 'none' ? null : url);
|
|
4398
|
+
} catch (error) {
|
|
4399
|
+
fail(error);
|
|
4400
|
+
}
|
|
4401
|
+
});
|
|
4402
|
+
|
|
4403
|
+
configCommand
|
|
4404
|
+
.command('set-site-url')
|
|
4405
|
+
.description('Set the App Shell origin `descix serve` proxies / to (env.siteUrl)')
|
|
4406
|
+
.argument('<url>', 'App Shell origin, or "none" to remove')
|
|
4407
|
+
.action(async (url) => {
|
|
4408
|
+
try {
|
|
4409
|
+
await configCommands.setSiteUrl(url === 'none' ? null : url);
|
|
4410
|
+
} catch (error) {
|
|
4411
|
+
fail(error);
|
|
3307
4412
|
}
|
|
3308
4413
|
});
|
|
3309
4414
|
|
|
@@ -3460,8 +4565,9 @@ function outputServiceReadmeInstructions(serviceName, appId, communityId, manife
|
|
|
3460
4565
|
|
|
3461
4566
|
console.log(chalk.white(`Your microservice needs a ${readmeFileName} file for tool discovery.
|
|
3462
4567
|
|
|
3463
|
-
📁 Required file: ${readmeFileName}
|
|
3464
|
-
|
|
4568
|
+
📁 Required file: ${readmeFileName} (local file in your microservice directory)
|
|
4569
|
+
▶️ Pass it explicitly: descix microservice register -r ./${readmeFileName}
|
|
4570
|
+
(or re-run with --skip-readme-check to register without tool discovery)
|
|
3465
4571
|
|
|
3466
4572
|
This README is vectorized and used by the \`tell_me_how\` command to help
|
|
3467
4573
|
users and AI agents discover your service's capabilities.
|
|
@@ -3537,7 +4643,7 @@ mcpCommand
|
|
|
3537
4643
|
try {
|
|
3538
4644
|
await mcpCommands.init();
|
|
3539
4645
|
} catch (error) {
|
|
3540
|
-
|
|
4646
|
+
fail(error);
|
|
3541
4647
|
}
|
|
3542
4648
|
});
|
|
3543
4649
|
|
|
@@ -3549,7 +4655,7 @@ mcpCommand
|
|
|
3549
4655
|
try {
|
|
3550
4656
|
await mcpCommands.test(options);
|
|
3551
4657
|
} catch (error) {
|
|
3552
|
-
|
|
4658
|
+
fail(error);
|
|
3553
4659
|
}
|
|
3554
4660
|
});
|
|
3555
4661
|
|
|
@@ -3560,7 +4666,7 @@ mcpCommand
|
|
|
3560
4666
|
try {
|
|
3561
4667
|
await mcpCommands.config();
|
|
3562
4668
|
} catch (error) {
|
|
3563
|
-
|
|
4669
|
+
fail(error);
|
|
3564
4670
|
}
|
|
3565
4671
|
});
|
|
3566
4672
|
|
|
@@ -3571,6 +4677,66 @@ mcpCommand
|
|
|
3571
4677
|
try {
|
|
3572
4678
|
await mcpCommands.quickstart();
|
|
3573
4679
|
} catch (error) {
|
|
4680
|
+
fail(error);
|
|
4681
|
+
}
|
|
4682
|
+
});
|
|
4683
|
+
|
|
4684
|
+
mcpCommand
|
|
4685
|
+
.command('execute')
|
|
4686
|
+
.description('Execute a registered MCP tool by name with JSON parameters')
|
|
4687
|
+
.requiredOption('--tool <name>', 'Tool/command name (e.g., beast_get_initiatives, beast_update_stream)')
|
|
4688
|
+
.option('--params <json>', 'JSON parameters for the tool', '{}')
|
|
4689
|
+
.option('-a, --app <app_id>', 'App context (sets app_id in params if not already present)')
|
|
4690
|
+
.option('--json', 'Output raw JSON response')
|
|
4691
|
+
.action(async (options) => {
|
|
4692
|
+
try {
|
|
4693
|
+
const apiClient = new DeSciXApiClient();
|
|
4694
|
+
await apiClient.initialize();
|
|
4695
|
+
await requireAuth(apiClient);
|
|
4696
|
+
|
|
4697
|
+
// Parse JSON params
|
|
4698
|
+
let params;
|
|
4699
|
+
try {
|
|
4700
|
+
params = JSON.parse(options.params);
|
|
4701
|
+
} catch (parseError) {
|
|
4702
|
+
console.error(chalk.red(`Invalid JSON in --params: ${parseError.message}`));
|
|
4703
|
+
console.error(chalk.gray('Example: --params \'{"initiative_id": "agentic-memory"}\''));
|
|
4704
|
+
process.exit(1);
|
|
4705
|
+
}
|
|
4706
|
+
|
|
4707
|
+
// Inject app_id if provided via --app flag and not already in params
|
|
4708
|
+
if (options.app && !params.app_id) {
|
|
4709
|
+
params.app_id = options.app;
|
|
4710
|
+
}
|
|
4711
|
+
|
|
4712
|
+
progress(options, chalk.gray(`Executing ${options.tool}...`));
|
|
4713
|
+
|
|
4714
|
+
const response = await apiClient.invoke(options.tool, params);
|
|
4715
|
+
|
|
4716
|
+
if (options.json) {
|
|
4717
|
+
// Raw JSON output for piping/scripting
|
|
4718
|
+
console.log(JSON.stringify(response, null, 2));
|
|
4719
|
+
} else {
|
|
4720
|
+
// Human-readable output
|
|
4721
|
+
const result = response.message || response;
|
|
4722
|
+
|
|
4723
|
+
if (result.success !== undefined) {
|
|
4724
|
+
console.log(chalk.green(`\nSuccess: ${result.message || 'Command executed successfully'}`));
|
|
4725
|
+
} else {
|
|
4726
|
+
console.log(chalk.green('\nResult:'));
|
|
4727
|
+
}
|
|
4728
|
+
|
|
4729
|
+
// Pretty-print the result, excluding verbose fields
|
|
4730
|
+
const displayResult = typeof result === 'object' ? result : { response: result };
|
|
4731
|
+
console.log(chalk.white(JSON.stringify(displayResult, null, 2)));
|
|
4732
|
+
}
|
|
4733
|
+
|
|
4734
|
+
console.log('');
|
|
4735
|
+
} catch (error) {
|
|
4736
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
4737
|
+
if (error.response) {
|
|
4738
|
+
console.error(chalk.gray(JSON.stringify(error.response, null, 2)));
|
|
4739
|
+
}
|
|
3574
4740
|
process.exit(1);
|
|
3575
4741
|
}
|
|
3576
4742
|
});
|
|
@@ -3581,7 +4747,7 @@ program
|
|
|
3581
4747
|
.command('tell-me-how')
|
|
3582
4748
|
.description('Discover platform tools using natural language (Intelligent MCP Mesh)')
|
|
3583
4749
|
.argument('<question>', 'What do you want to accomplish? Use natural language.')
|
|
3584
|
-
.option('-s, --scope <scope>', 'Search scope: project, entitlements (default), discovery', 'entitlements')
|
|
4750
|
+
.option('-s, --scope <scope>', 'Search scope (validated against the published tell_me_how enum): bootstrap, artifact, project, entitlements (default), discovery', 'entitlements')
|
|
3585
4751
|
.option('-j, --json', 'Output raw JSON response')
|
|
3586
4752
|
.action(async (question, options) => {
|
|
3587
4753
|
try {
|
|
@@ -3590,52 +4756,59 @@ program
|
|
|
3590
4756
|
|
|
3591
4757
|
const { scope, json: jsonOutput } = options;
|
|
3592
4758
|
|
|
3593
|
-
// Validate scope
|
|
3594
|
-
|
|
4759
|
+
// Validate scope against the PUBLISHED enum, never a hand-copy. This list used to be a
|
|
4760
|
+
// third mirror (['project','entitlements','discovery']) of a contract owned in
|
|
4761
|
+
// @descix/platform-api/mcp-tools, and on 2026-08-18 all three copies disagreed: the handler
|
|
4762
|
+
// knew 6 scopes, the published enum 4, this CLI 3 — so 'artifact' and 'bootstrap' were
|
|
4763
|
+
// rejected here despite working on the wire. One owner, read at runtime.
|
|
4764
|
+
const { NATIVE_MCP_TOOLS } = await import('@descix/platform-api/mcp-tools');
|
|
4765
|
+
const validScopes = NATIVE_MCP_TOOLS
|
|
4766
|
+
.find(t => t.name === 'tell_me_how')?.inputSchema?.properties?.scope?.enum;
|
|
4767
|
+
if (!Array.isArray(validScopes) || validScopes.length === 0) {
|
|
4768
|
+
console.error(chalk.red("tell_me_how scope enum missing from @descix/platform-api/mcp-tools — cannot validate."));
|
|
4769
|
+
process.exit(1);
|
|
4770
|
+
}
|
|
3595
4771
|
if (!validScopes.includes(scope)) {
|
|
3596
4772
|
console.error(chalk.red(`Invalid scope '${scope}'. Must be one of: ${validScopes.join(', ')}`));
|
|
3597
4773
|
process.exit(1);
|
|
3598
4774
|
}
|
|
3599
4775
|
|
|
3600
|
-
//
|
|
4776
|
+
// Project context for 'project' scope, read from the v2.1 workspace.
|
|
4777
|
+
//
|
|
4778
|
+
// This previously read `primaryCommunity`, `directoryMappings` and `defaultContext` — all
|
|
4779
|
+
// v1-format keys. A v2.1 workspace ({version, workspaceRoot, type, env{...}, driveConfig})
|
|
4780
|
+
// has NONE of them, so this always produced {community_ids:[], app_ids:[]} and the server
|
|
4781
|
+
// rejected every call with "project scope requires project_context with community_ids or
|
|
4782
|
+
// app_ids". `--scope project` was dead on this surface for exactly as long as v2.1 has been
|
|
4783
|
+
// the format.
|
|
4784
|
+
//
|
|
4785
|
+
// v2.1 carries APP IDS ONLY (env.platform.appId, env.products[].appId) — no community ids.
|
|
4786
|
+
// We therefore send app_ids alone and let the server resolve ownership from Products.
|
|
4787
|
+
// Deriving a community_id by splitting an app_id would violate the platform invariant that
|
|
4788
|
+
// app ids are OPAQUE to routing and lookups.
|
|
3601
4789
|
let project_context = null;
|
|
3602
4790
|
if (scope === 'project') {
|
|
4791
|
+
let wsConfig;
|
|
3603
4792
|
try {
|
|
3604
4793
|
const { WorkspaceConfig } = await import('../lib/workspace-config.js');
|
|
3605
|
-
|
|
3606
|
-
if (wsConfig) {
|
|
3607
|
-
// Extract community_ids from primaryCommunity and directoryMappings
|
|
3608
|
-
const communityIds = new Set();
|
|
3609
|
-
const appIds = new Set();
|
|
3610
|
-
|
|
3611
|
-
if (wsConfig.primaryCommunity) {
|
|
3612
|
-
communityIds.add(wsConfig.primaryCommunity);
|
|
3613
|
-
}
|
|
3614
|
-
|
|
3615
|
-
// Extract from directoryMappings
|
|
3616
|
-
for (const mapping of Object.values(wsConfig.directoryMappings || {})) {
|
|
3617
|
-
if (mapping.communityId) communityIds.add(mapping.communityId);
|
|
3618
|
-
if (mapping.appId) appIds.add(mapping.appId);
|
|
3619
|
-
}
|
|
3620
|
-
|
|
3621
|
-
// Extract from defaultContext
|
|
3622
|
-
if (wsConfig.defaultContext?.communityId) {
|
|
3623
|
-
communityIds.add(wsConfig.defaultContext.communityId);
|
|
3624
|
-
}
|
|
3625
|
-
if (wsConfig.defaultContext?.appId) {
|
|
3626
|
-
appIds.add(wsConfig.defaultContext.appId);
|
|
3627
|
-
}
|
|
3628
|
-
|
|
3629
|
-
project_context = {
|
|
3630
|
-
community_ids: Array.from(communityIds),
|
|
3631
|
-
app_ids: Array.from(appIds)
|
|
3632
|
-
};
|
|
3633
|
-
}
|
|
4794
|
+
wsConfig = await WorkspaceConfig.load(process.cwd());
|
|
3634
4795
|
} catch (e) {
|
|
3635
4796
|
console.error(chalk.yellow(`⚠️ Could not read .descix/workspace.json for project scope`));
|
|
3636
4797
|
console.error(chalk.gray(` Run 'descix init' first, or use --scope entitlements`));
|
|
3637
4798
|
process.exit(1);
|
|
3638
4799
|
}
|
|
4800
|
+
const appIds = new Set();
|
|
4801
|
+
if (wsConfig?.env?.platform?.appId) appIds.add(wsConfig.env.platform.appId);
|
|
4802
|
+
for (const product of wsConfig?.env?.products || []) {
|
|
4803
|
+
if (product?.appId) appIds.add(product.appId);
|
|
4804
|
+
}
|
|
4805
|
+
// Fail LOUD rather than sending an empty context the server can only reject.
|
|
4806
|
+
if (appIds.size === 0) {
|
|
4807
|
+
console.error(chalk.red("project scope: no apps found in .descix/workspace.json (env.platform.appId / env.products[].appId)."));
|
|
4808
|
+
console.error(chalk.gray(" Use --scope entitlements, or add products to the workspace."));
|
|
4809
|
+
process.exit(1);
|
|
4810
|
+
}
|
|
4811
|
+
project_context = { app_ids: Array.from(appIds) };
|
|
3639
4812
|
}
|
|
3640
4813
|
|
|
3641
4814
|
console.log(chalk.cyan(`\n🔍 Searching for tools... (scope: ${scope})\n`));
|
|
@@ -3707,78 +4880,19 @@ program
|
|
|
3707
4880
|
}
|
|
3708
4881
|
});
|
|
3709
4882
|
|
|
3710
|
-
// ============ Folder Commands ============
|
|
3711
|
-
|
|
3712
|
-
const folderCommand = program
|
|
3713
|
-
.command('folder')
|
|
3714
|
-
.description('Drive folder management');
|
|
3715
|
-
|
|
3716
|
-
folderCommand
|
|
3717
|
-
.command('set')
|
|
3718
|
-
.description('Set folder ID for an entity')
|
|
3719
|
-
.option('--user <folderId>', 'Set user base folder')
|
|
3720
|
-
.option('--community <communityId>', 'Set community folder (requires folderId positional)')
|
|
3721
|
-
.option('--app <communityApp>', 'Set app folder: "community app" (requires folderId positional)')
|
|
3722
|
-
.option('--kb <communityAppKb>', 'Set KB folder: "community app kb" (requires folderId positional)')
|
|
3723
|
-
.argument('[folderId]', 'Folder ID (for community/app/kb)')
|
|
3724
|
-
.action(async (folderId, options) => {
|
|
3725
|
-
try {
|
|
3726
|
-
await folderCommands.setFolder({ ...options, folderId });
|
|
3727
|
-
} catch (error) {
|
|
3728
|
-
process.exit(1);
|
|
3729
|
-
}
|
|
3730
|
-
});
|
|
3731
|
-
|
|
3732
|
-
folderCommand
|
|
3733
|
-
.command('get')
|
|
3734
|
-
.description('Get folder info for an entity')
|
|
3735
|
-
.option('--user', 'Get user base folder')
|
|
3736
|
-
.option('--community <communityId>', 'Get community folder')
|
|
3737
|
-
.option('--app <communityApp>', 'Get app folder: "community app"')
|
|
3738
|
-
.option('--kb <communityAppKb>', 'Get KB folder: "community app kb"')
|
|
3739
|
-
.action(async (options) => {
|
|
3740
|
-
try {
|
|
3741
|
-
await folderCommands.getFolder(options);
|
|
3742
|
-
} catch (error) {
|
|
3743
|
-
process.exit(1);
|
|
3744
|
-
}
|
|
3745
|
-
});
|
|
3746
|
-
|
|
3747
|
-
folderCommand
|
|
3748
|
-
.command('allocate')
|
|
3749
|
-
.description('Allocate a new folder for an entity')
|
|
3750
|
-
.option('--app <communityApp>', 'Allocate app folder: "community app"')
|
|
3751
|
-
.option('--kb <communityAppKb>', 'Allocate KB folder: "community app kb"')
|
|
3752
|
-
.option('-n, --name <name>', 'Custom folder name')
|
|
3753
|
-
.action(async (options) => {
|
|
3754
|
-
try {
|
|
3755
|
-
await folderCommands.allocateFolder(options);
|
|
3756
|
-
} catch (error) {
|
|
3757
|
-
process.exit(1);
|
|
3758
|
-
}
|
|
3759
|
-
});
|
|
3760
|
-
|
|
3761
|
-
folderCommand
|
|
3762
|
-
.command('validate <folderId>')
|
|
3763
|
-
.description('Validate folder access')
|
|
3764
|
-
.action(async (folderId, options) => {
|
|
3765
|
-
try {
|
|
3766
|
-
await folderCommands.validateFolder(folderId, options);
|
|
3767
|
-
} catch (error) {
|
|
3768
|
-
process.exit(1);
|
|
3769
|
-
}
|
|
3770
|
-
});
|
|
3771
|
-
|
|
3772
4883
|
// ============ Update Commands (Context-Driven) ============
|
|
3773
4884
|
|
|
3774
4885
|
const updateCommand = program
|
|
3775
4886
|
.command('update')
|
|
3776
|
-
|
|
3777
|
-
|
|
4887
|
+
// `update kb` is REMOVED — kb sync has exactly one surface, `descix kb corpus sync`.
|
|
4888
|
+
// `update app` and `update site` are NOT kb-sync surfaces and are untouched, so the
|
|
4889
|
+
// `update` verb itself stays registered. `update all`/`auto` keep doing app and site and
|
|
4890
|
+
// no longer touch the KB; they print where kb sync went rather than failing, because
|
|
4891
|
+
// failing them would delete non-kb functionality this change never targeted.
|
|
4892
|
+
.description('Context-driven resource sync for app and site (auto-detects from workspace). For knowledge bases use `descix kb corpus sync`.')
|
|
4893
|
+
.argument('[type]', 'Update type: app, site, all (auto-detects if not specified)')
|
|
3778
4894
|
.option('-c, --community <id>', 'Community ID (optional; app_id sufficient for Unified Registry)')
|
|
3779
4895
|
.option('-a, --app <id>', 'App ID (globally unique; required if not in app directory)')
|
|
3780
|
-
.option('--kb <name>', 'Specific KB to update (for kb type)')
|
|
3781
|
-
.option('--stage1', 'KB: Local to Drive only (skip vectorization)')
|
|
3782
4896
|
.option('--preview', 'Site: Deploy to preview URL')
|
|
3783
4897
|
.option('--build <cmd>', 'Site: Run build command first')
|
|
3784
4898
|
.option('--full', 'Force full sync (ignore delta)')
|
|
@@ -3790,7 +4904,7 @@ const updateCommand = program
|
|
|
3790
4904
|
await updateCommands.updateApp(options);
|
|
3791
4905
|
break;
|
|
3792
4906
|
case 'kb':
|
|
3793
|
-
|
|
4907
|
+
refuseRetiredKbSync('descix update kb', chalk.red);
|
|
3794
4908
|
break;
|
|
3795
4909
|
case 'site':
|
|
3796
4910
|
await updateCommands.updateSite({
|
|
@@ -3799,14 +4913,19 @@ const updateCommand = program
|
|
|
3799
4913
|
});
|
|
3800
4914
|
break;
|
|
3801
4915
|
case 'all':
|
|
4916
|
+
console.log(chalk.gray(` (knowledge bases are not included: use \`${CANONICAL_KB_SYNC}\`)`));
|
|
3802
4917
|
await updateCommands.updateAll(options);
|
|
3803
4918
|
break;
|
|
3804
4919
|
default:
|
|
3805
|
-
// Auto-detect
|
|
4920
|
+
// Auto-detect. NO blanket "KBs are excluded" line here: auto resolves to exactly
|
|
4921
|
+
// one target, and if that target is the KB the user gets the refusal by name.
|
|
4922
|
+
// Printing the advisory first would announce an exclusion and then refuse for
|
|
4923
|
+
// that exact reason — an advisory that fires on correct behaviour trains people
|
|
4924
|
+
// to ignore advisories.
|
|
3806
4925
|
await updateCommands.updateAuto(options);
|
|
3807
4926
|
}
|
|
3808
4927
|
} catch (error) {
|
|
3809
|
-
|
|
4928
|
+
fail(error);
|
|
3810
4929
|
}
|
|
3811
4930
|
});
|
|
3812
4931
|
|
|
@@ -3814,15 +4933,22 @@ const updateCommand = program
|
|
|
3814
4933
|
|
|
3815
4934
|
program
|
|
3816
4935
|
.command('serve')
|
|
3817
|
-
.description('Start the unified local gateway (
|
|
3818
|
-
.option('-p, --port <port>', 'Gateway port
|
|
4936
|
+
.description('Start the unified local gateway (one HTTPS origin: App Shell at /, your app at /p/<app>, /apifront to the API)')
|
|
4937
|
+
.option('-p, --port <port>', 'Gateway port for this run (default: env.gateway.port, else 5173)')
|
|
3819
4938
|
.option('-w, --workspace <path>', 'Workspace root override')
|
|
4939
|
+
.option('--site-url <url>', 'App Shell target for this run (default: env.siteUrl, a local platform site, else the API origin)')
|
|
4940
|
+
.option('-a, --app <id>', 'App to serve standalone (default: detected from the directory you are standing in)')
|
|
3820
4941
|
.action(async (options) => {
|
|
3821
4942
|
try {
|
|
3822
4943
|
const { runServe } = await import('../lib/commands/serve.js');
|
|
3823
4944
|
await runServe({
|
|
3824
|
-
|
|
4945
|
+
// No default here: an unset flag must stay UNSET so the gateway's
|
|
4946
|
+
// resolveGatewayPort can see env.gateway.port. A CLI-side default would
|
|
4947
|
+
// shadow the workspace's own port — the map/server disagreement bug.
|
|
4948
|
+
port: options.port !== undefined ? parseInt(options.port, 10) : undefined,
|
|
3825
4949
|
workspaceRoot: options.workspace || process.cwd(),
|
|
4950
|
+
siteUrl: options.siteUrl,
|
|
4951
|
+
app: options.app,
|
|
3826
4952
|
});
|
|
3827
4953
|
} catch (error) {
|
|
3828
4954
|
console.error(chalk.red('Gateway error:', error.message));
|
|
@@ -3836,7 +4962,6 @@ program
|
|
|
3836
4962
|
.command('quickstart')
|
|
3837
4963
|
.description('One-command setup: auth → workspace → agent files → MCP config')
|
|
3838
4964
|
.option('-u, --url <url>', 'API URL override')
|
|
3839
|
-
.option('--dev', 'Use development server (https://localhost:4000)')
|
|
3840
4965
|
.action(async (options) => {
|
|
3841
4966
|
const { generateAgentFiles, generateMcpConfig } = await import('../lib/agent-files.js');
|
|
3842
4967
|
const { WalletFileManager } = await import('../lib/wallet-file.js');
|
|
@@ -3859,7 +4984,6 @@ program
|
|
|
3859
4984
|
if (needsLogin) {
|
|
3860
4985
|
const loginOptions = {};
|
|
3861
4986
|
if (options.url) loginOptions.url = options.url;
|
|
3862
|
-
if (options.dev) loginOptions.dev = true;
|
|
3863
4987
|
await authCommands.loginDevice(loginOptions);
|
|
3864
4988
|
}
|
|
3865
4989
|
|