@descix/cli 1.0.7 → 1.0.9
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/README.md +1 -1
- package/agent-assets/guides/README.md +1 -1
- package/agent-assets/guides/dynamic-site-pattern.md +7 -16
- package/agent-assets/instructions/app-asset-validation.md +1 -1
- package/agent-assets/reference/architecture.md +20 -25
- package/agent-assets/reference/local-dev.md +1 -6
- package/agent-assets/reference/mcp-integration.md +15 -9
- package/agent-assets/reference/platform-api-consumer.md +11 -5
- package/agent-assets/reference/workspace-config.md +2 -2
- package/bin/descix.js +211 -688
- package/lib/command-visibility.js +213 -0
- package/lib/commands/corpus.js +2 -5
- package/lib/commands/kb.js +2 -2
- package/lib/commands/media-upload.js +176 -0
- package/lib/commands/retired-kb-sync.js +5 -27
- package/lib/commands/status.js +0 -9
- package/lib/output.js +31 -0
- package/lib/sdk-assets.js +28 -0
- package/lib/verb-invokes.generated.json +233 -0
- package/lib/workspace-config.js +9 -44
- package/package.json +6 -2
- package/templates/agent-claude.md +1 -3
- package/templates/agent-cline.md +1 -2
- package/templates/agent-copilot.md +1 -2
- package/templates/agent-cursor.md +1 -2
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +1 -1
- package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +2 -3
- package/agent-assets/rules/descix_mcp.template.mdc +0 -243
- package/lib/commands/briefer/errors.js +0 -88
- package/lib/commands/briefer/index.js +0 -322
- package/lib/commands/briefer/sources/canonical-sources.js +0 -129
- package/lib/commands/briefer/sources/entitlements.js +0 -220
- package/lib/commands/briefer/sources/environments.js +0 -205
- package/lib/commands/briefer/sources/identifiers.js +0 -214
- package/lib/commands/briefer/sources/microservice-deploy.js +0 -267
- package/lib/commands/briefer/sources/routing.js +0 -275
- package/lib/commands/briefer/sources/what-is-not.js +0 -223
- package/lib/commands/briefer/stitcher.js +0 -134
- package/lib/commands/briefer/util/source-reader.js +0 -502
- package/lib/commands/mcp.js +0 -273
- package/lib/commands/update.js +0 -581
- package/lib/wizard/setup.js +0 -585
- package/templates/descix_mcp.mdc +0 -122
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Visibility — the PUBLIC-vs-admin CLI help listing.
|
|
3
|
+
*
|
|
4
|
+
* CEO ruling (verbatim): "The microservice manifest consumed by DeSciX core should determine
|
|
5
|
+
* what gets exposed via MCP (and therefore CLI listing should be easily exposed there too for
|
|
6
|
+
* the core itself) - shouldn't we reuse that surface." And: "It's not a worry that admin
|
|
7
|
+
* commands can be discovered by normal users but rather we are trying to avoid the flood of
|
|
8
|
+
* noise so it is acceptable to have two lists that can be derived from the same manifest."
|
|
9
|
+
*
|
|
10
|
+
* THE CLASS COMES FROM THE SERVER AT RUNTIME (`get_command_surface`, guest-allowed) — there is
|
|
11
|
+
* NO committed class table in this repo. The only thing committed here is
|
|
12
|
+
* lib/verb-invokes.generated.json, a CLI-owned fact ("this verb invokes these commands"),
|
|
13
|
+
* regenerated by scripts/generate-verb-invokes.mjs from bin/descix.js + lib/** itself.
|
|
14
|
+
*
|
|
15
|
+
* A verb is hidden from the default listing when it invokes at least one command AND every
|
|
16
|
+
* command it invokes is classified 'admin' by the server. A verb with no recorded invokes
|
|
17
|
+
* (local-only, or a verb whose only invoke call sites are dynamic — see
|
|
18
|
+
* scripts/lib/verb-invoke-graph.mjs) stays visible: absence of information is not evidence of
|
|
19
|
+
* admin-only-ness. A GROUP (a command with children and no action of its own) is hidden when
|
|
20
|
+
* every one of its children ends up hidden — hiding is transitive up the tree, computed
|
|
21
|
+
* bottom-up from the LIVE commander tree (never a second, hand-kept tree).
|
|
22
|
+
*
|
|
23
|
+
* Hiding is LISTING ONLY. A hidden verb still EXECUTES normally when typed; the server's own
|
|
24
|
+
* permission gate (checkCommandPermission) is the actual enforcement, unchanged by this file.
|
|
25
|
+
*/
|
|
26
|
+
import fs from 'fs/promises';
|
|
27
|
+
import path from 'path';
|
|
28
|
+
import os from 'os';
|
|
29
|
+
import VERB_INVOKES from './verb-invokes.generated.json' with { type: 'json' };
|
|
30
|
+
|
|
31
|
+
const CACHE_PATH = path.join(os.homedir(), '.descix', 'command-surface-cache.json');
|
|
32
|
+
/** Short TTL: this is a LISTING convenience cache, not a security boundary — a stale read only
|
|
33
|
+
* ever risks showing (or hiding) a help row for a few minutes, never changes what executes. */
|
|
34
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* ONE explicit, CLI-owned declaration: Drive content authoring (`descix drive pull/push`) needs
|
|
38
|
+
* infrastructure admin credentials (Google ADC / a Drive service account) that `get_command_surface`
|
|
39
|
+
* has no visibility into — it is not gated by any COMMAND_PERMISSIONS entry the server classifies
|
|
40
|
+
* 'admin', so it can never be derived from the manifest. Per the CEO's ruling these stay OUT of
|
|
41
|
+
* the public CLI listing (internal Drive authoring still uses them — hidden, never deleted). The
|
|
42
|
+
* `drive` group itself is included for clarity; it would also be hidden automatically once both
|
|
43
|
+
* of its children are (see applyVisibility's group rule), but naming it here keeps this ONE set
|
|
44
|
+
* the single place that answers "why is drive hidden" rather than splitting the answer across a
|
|
45
|
+
* declaration and an inference.
|
|
46
|
+
*/
|
|
47
|
+
export const INFRA_ADMIN_HIDDEN_PATHS = new Set(['drive', 'drive pull', 'drive push']);
|
|
48
|
+
|
|
49
|
+
/** @returns {Record<string,string[]>} the committed, generated verb -> invoked-commands map. */
|
|
50
|
+
export function getVerbInvokeMap() {
|
|
51
|
+
return VERB_INVOKES;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fetch the server's per-command visibility surface as a guest (no login needed).
|
|
56
|
+
* @param {import('./api-client.js').DeSciXApiClient} apiClient
|
|
57
|
+
* @returns {Promise<{ service: string, manifest_hash: string, commands: Record<string,'public'|'admin'> }>}
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchCommandSurface(apiClient) {
|
|
60
|
+
const resp = await apiClient.invoke('get_command_surface', {}, { allowGuest: true });
|
|
61
|
+
const msg = resp?.message || resp;
|
|
62
|
+
if (!msg || typeof msg.commands !== 'object' || msg.commands === null) {
|
|
63
|
+
throw new Error(`get_command_surface returned an unexpected shape: ${JSON.stringify(resp)}`);
|
|
64
|
+
}
|
|
65
|
+
return msg;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readCache() {
|
|
69
|
+
try {
|
|
70
|
+
const raw = await fs.readFile(CACHE_PATH, 'utf8');
|
|
71
|
+
return JSON.parse(raw);
|
|
72
|
+
} catch {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function writeCache(cache) {
|
|
78
|
+
try {
|
|
79
|
+
await fs.mkdir(path.dirname(CACHE_PATH), { recursive: true });
|
|
80
|
+
await fs.writeFile(CACHE_PATH, JSON.stringify(cache, null, 2), 'utf8');
|
|
81
|
+
} catch {
|
|
82
|
+
// Best-effort. A cache write failure must never break help output — the caller already has
|
|
83
|
+
// the freshly-fetched surface in hand and can render with it regardless.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The server surface for `origin`, from the user-level cache (keyed by origin, short TTL) when
|
|
89
|
+
* fresh, else fetched live and cached. Throws if the fetch fails (network down, old server
|
|
90
|
+
* without the command, etc.) — the caller decides what "help shows everything" means.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} origin
|
|
93
|
+
* @param {import('./api-client.js').DeSciXApiClient} apiClient
|
|
94
|
+
* @returns {Promise<{ commands: Record<string,'public'|'admin'>, fromCache: boolean }>}
|
|
95
|
+
*/
|
|
96
|
+
export async function getCommandSurface(origin, apiClient) {
|
|
97
|
+
const cache = await readCache();
|
|
98
|
+
const entry = cache[origin];
|
|
99
|
+
if (entry && typeof entry.fetchedAt === 'number' && (Date.now() - entry.fetchedAt) < CACHE_TTL_MS) {
|
|
100
|
+
return { commands: entry.surface.commands, fromCache: true };
|
|
101
|
+
}
|
|
102
|
+
const surface = await fetchCommandSurface(apiClient);
|
|
103
|
+
cache[origin] = { surface, fetchedAt: Date.now() };
|
|
104
|
+
await writeCache(cache);
|
|
105
|
+
return { commands: surface.commands, fromCache: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Should a single LEAF verb (identified by its full space-joined path, e.g. "kb corpus sync") be
|
|
110
|
+
* hidden from the default listing, given the server's command->visibility surface?
|
|
111
|
+
*
|
|
112
|
+
* @param {string} verbPath
|
|
113
|
+
* @param {Record<string,'public'|'admin'>} surfaceCommands
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
export function shouldHideLeaf(verbPath, surfaceCommands) {
|
|
117
|
+
if (INFRA_ADMIN_HIDDEN_PATHS.has(verbPath)) return true;
|
|
118
|
+
const invokes = VERB_INVOKES[verbPath];
|
|
119
|
+
if (!invokes || invokes.length === 0) return false; // no recorded invokes -> stays visible
|
|
120
|
+
return invokes.every((cmd) => surfaceCommands[cmd] === 'admin');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply hide/show to the LIVE commander tree rooted at `program`, bottom-up. Never reads or
|
|
125
|
+
* builds a second command tree — `cmd.commands` / `cmd._actionHandler` (commander's own runtime
|
|
126
|
+
* state) is the only source of "what verbs exist and how they nest", so this can never drift
|
|
127
|
+
* from the tree `--help` actually renders.
|
|
128
|
+
*
|
|
129
|
+
* @param {import('commander').Command} program
|
|
130
|
+
* @param {Record<string,'public'|'admin'>} surfaceCommands
|
|
131
|
+
*/
|
|
132
|
+
export function applyVisibility(program, surfaceCommands) {
|
|
133
|
+
function visit(cmd, pathTokens) {
|
|
134
|
+
const verbPath = pathTokens.join(' ');
|
|
135
|
+
const hasChildren = Array.isArray(cmd.commands) && cmd.commands.length > 0;
|
|
136
|
+
// ADDITIVE ONLY. Some commands are registered `{ hidden: true }` by the CLI itself for
|
|
137
|
+
// reasons that have nothing to do with server admin-gating — see the retired-surface
|
|
138
|
+
// registrations in lib/commands/retired-kb-sync.js, each a deliberate loud-refusal stub kept
|
|
139
|
+
// out of the default listing so it doesn't clutter it, while still working and refusing
|
|
140
|
+
// clearly when typed. This layer may HIDE a visible command; it must never REVEAL a command
|
|
141
|
+
// the CLI already chose to hide. MEASURED: an early version unconditionally overwrote
|
|
142
|
+
// `_hidden`, which un-hid a retired-surface stub the moment its verb had no generated-map
|
|
143
|
+
// entry (it invokes no /apifront command at all, so its invoke list is empty).
|
|
144
|
+
const alreadyHidden = !!cmd._hidden;
|
|
145
|
+
|
|
146
|
+
if (hasChildren) {
|
|
147
|
+
let allChildrenHidden = true;
|
|
148
|
+
for (const child of cmd.commands) {
|
|
149
|
+
const childHidden = visit(child, [...pathTokens, child.name()]);
|
|
150
|
+
if (!childHidden) allChildrenHidden = false;
|
|
151
|
+
}
|
|
152
|
+
if (pathTokens.length === 0) return false; // never hide the root program
|
|
153
|
+
if (cmd._actionHandler) {
|
|
154
|
+
// A command that is BOTH a group and its own runnable leaf (not used in this CLI today,
|
|
155
|
+
// but commander allows it) is judged as a leaf: its own invokes decide it, independent
|
|
156
|
+
// of its children.
|
|
157
|
+
const hidden = alreadyHidden || shouldHideLeaf(verbPath, surfaceCommands);
|
|
158
|
+
cmd._hidden = hidden;
|
|
159
|
+
return hidden;
|
|
160
|
+
}
|
|
161
|
+
cmd._hidden = alreadyHidden || allChildrenHidden;
|
|
162
|
+
return cmd._hidden;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (pathTokens.length === 0) return false; // the root has no children in practice, but guard anyway
|
|
166
|
+
const hidden = alreadyHidden || shouldHideLeaf(verbPath, surfaceCommands);
|
|
167
|
+
cmd._hidden = hidden;
|
|
168
|
+
return hidden;
|
|
169
|
+
}
|
|
170
|
+
visit(program, []);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Is this invocation one that will render `--help` output? Only these fetch the network — every
|
|
175
|
+
* other invocation must stay fast and offline-safe.
|
|
176
|
+
*
|
|
177
|
+
* Recognizes: `-h` / `--help` anywhere, `help` in subcommand position, a bare invocation (no positional tokens once
|
|
178
|
+
* global flags are stripped), and a "bare group" invocation (the resolved command has children
|
|
179
|
+
* but no action handler of its own — e.g. `descix app` with no subcommand, which commander
|
|
180
|
+
* renders as help even though the CLI never called outputHelp() itself).
|
|
181
|
+
*
|
|
182
|
+
* @param {import('commander').Command} program
|
|
183
|
+
* @param {string[]} argv - process.argv.slice(2)
|
|
184
|
+
* @param {{ valueFlags?: string[], booleanFlags?: string[] }} [globalFlags]
|
|
185
|
+
* @returns {boolean}
|
|
186
|
+
*/
|
|
187
|
+
export function isHelpInvocation(program, argv, globalFlags = {}) {
|
|
188
|
+
if (argv.length === 0) return true;
|
|
189
|
+
if (argv.includes('-h') || argv.includes('--help')) return true;
|
|
190
|
+
if (argv.includes('-V') || argv.includes('--version')) return false; // prints a version, renders no listing
|
|
191
|
+
|
|
192
|
+
const valueFlags = new Set(globalFlags.valueFlags || []);
|
|
193
|
+
const booleanFlags = new Set(globalFlags.booleanFlags || []);
|
|
194
|
+
const positional = [];
|
|
195
|
+
for (let i = 0; i < argv.length; i++) {
|
|
196
|
+
const tok = argv[i];
|
|
197
|
+
if (valueFlags.has(tok)) { i++; continue; }
|
|
198
|
+
if (booleanFlags.has(tok)) continue;
|
|
199
|
+
if (tok.startsWith('-')) continue; // an option belonging to whatever command it turns out to be
|
|
200
|
+
positional.push(tok);
|
|
201
|
+
}
|
|
202
|
+
if (positional.length === 0) return true;
|
|
203
|
+
|
|
204
|
+
let cmd = program;
|
|
205
|
+
for (const tok of positional) {
|
|
206
|
+
if (cmd._actionHandler) break; // reached a leaf; remaining tokens are ITS args, not subcommands
|
|
207
|
+
if (tok === 'help') return true; // commander's help subcommand, only in subcommand position
|
|
208
|
+
const next = (cmd.commands || []).find((c) => c.name() === tok || (c.aliases && c.aliases().includes(tok)));
|
|
209
|
+
if (!next) return false; // unresolvable path — commander's own error path, not ours to fetch for
|
|
210
|
+
cmd = next;
|
|
211
|
+
}
|
|
212
|
+
return !cmd._actionHandler;
|
|
213
|
+
}
|
package/lib/commands/corpus.js
CHANGED
|
@@ -452,16 +452,13 @@ export async function runCorpusSync(apiClient, options) {
|
|
|
452
452
|
const missingKbNames = requestedKbNames.filter(name => !registeredKbNames.has(name));
|
|
453
453
|
if (missingKbNames.length > 0) {
|
|
454
454
|
// Hard-fail: every KB we are about to sync into must already be registered in Firestore.
|
|
455
|
-
// No hardcoded fallback. `app init` is the verb that
|
|
456
|
-
// git-mode app (init_git_mode_kb, bin/descix.js:1163). `kb create` is Drive-mode
|
|
457
|
-
// wired (create_skeleton_kb, :2087) and fails here — filed as its own defect.
|
|
455
|
+
// No hardcoded fallback. `app init --kb` is the verb that creates a KB (init_git_mode_kb).
|
|
458
456
|
spinner.fail(`Unregistered KB(s) for app "${appId}"`);
|
|
459
457
|
throw new Error(
|
|
460
458
|
`KnowledgeBase document(s) missing in Firestore for app "${appId}": ${missingKbNames.join(', ')}.\n` +
|
|
461
459
|
`Registered KBs for this app: ${[...registeredKbNames].join(', ') || '(none)'}.\n` +
|
|
462
460
|
`Syncing vectors into Pinecone without a matching KB doc would orphan them (chat queries hard-fail).\n` +
|
|
463
|
-
`Fix: run \`descix app init -a ${appId}
|
|
464
|
-
`(\`descix kb create\` currently routes through create_skeleton_kb and FAILS on a git-mode app.)`
|
|
461
|
+
`Fix: run \`descix app init -a ${appId} --kb <kb_name>\` for each missing KB — it creates the KB and needs no Drive folder.`
|
|
465
462
|
);
|
|
466
463
|
}
|
|
467
464
|
|
package/lib/commands/kb.js
CHANGED
|
@@ -179,9 +179,9 @@ export async function runKbPush(apiClient, options) {
|
|
|
179
179
|
const onConflictPrompt = options.interactive ? async (fileName, fileInfo) => {
|
|
180
180
|
spinner.stop();
|
|
181
181
|
requireInteractive({
|
|
182
|
-
what: 'descix
|
|
182
|
+
what: 'descix drive push --interactive',
|
|
183
183
|
question: `File "${fileName}" exists in Drive. Overwrite or skip?`,
|
|
184
|
-
nonInteractiveForm: ['descix
|
|
184
|
+
nonInteractiveForm: ['descix drive push # without --interactive, conflicts resolve without a prompt']
|
|
185
185
|
});
|
|
186
186
|
const { action } = await inquirer.prompt([{
|
|
187
187
|
type: 'list',
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `descix app media-upload` — upload media/asset files to an app's GCS assets prefix via the
|
|
3
|
+
* API surface (WS-V1-PURGE Phase 1, item 2; media-via-API-surface PLATFORM half; storage-cap
|
|
4
|
+
* ferrying added WS-MEDIA-QUOTA per CEO ruling 2026-09-16: "Yes you can build with a 200MB
|
|
5
|
+
* size limit for now until we get credit based metering for more storage.").
|
|
6
|
+
*
|
|
7
|
+
* This is the canonical, filesystem-free way to get app media (podcast audio, cover art, etc.)
|
|
8
|
+
* into the platform: request a short-lived signed PUT token from the API surface
|
|
9
|
+
* (`get_asset_upload_token` over /apifront, user-session authed), upload each file straight to
|
|
10
|
+
* GCS, and print the server's asset references. An app microservice then fetches an uploaded
|
|
11
|
+
* asset by reference over the Core broker via `get_app_asset` (no shared local filesystem
|
|
12
|
+
* needed). App owners, community admins and platform admins may upload — the server is the
|
|
13
|
+
* sole authority on that and on the app's storage quota (200 MB per app today; nothing here
|
|
14
|
+
* pre-validates either).
|
|
15
|
+
*
|
|
16
|
+
* SERVER IS THE SOLE AUTHORITY (super-DRY: never re-derive a server-owned fact client-side):
|
|
17
|
+
* - quota, path validity (no leading `/`, no backslash, no empty/`.`/`..` segment) and caller
|
|
18
|
+
* permission are enforced ONLY by `get_asset_upload_token`; a refusal there throws with the
|
|
19
|
+
* server's message verbatim (`DeSciXApiClient#invoke`) and this module does not catch it —
|
|
20
|
+
* let it propagate so the caller prints exactly what the server said and exits non-zero.
|
|
21
|
+
* - `--prefix` is passed through UNCHANGED (no slash-stripping): stripping could silently mask
|
|
22
|
+
* a server-side path refusal the server is best positioned to name.
|
|
23
|
+
* - Each file is PUT with the server's `upload_headers[path]` bag VERBATIM (precedent: `site
|
|
24
|
+
* upload`'s `ferriedHeaders` — the server binds Content-Type and
|
|
25
|
+
* `x-goog-content-length-range` into the v4 signed-URL signature, so a re-derived header
|
|
26
|
+
* 403s). Size is still read from `fs.stat`, since GCS itself rejects a body whose length
|
|
27
|
+
* disagrees with the declared range.
|
|
28
|
+
* - `public_url` and `storage` are printed exactly as the server returned them — never
|
|
29
|
+
* reconstructed from `bucket` + `gcs_path` (that WAS a client-side re-derivation of a server
|
|
30
|
+
* fact; removed here rather than perpetuated).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import * as fs from 'fs/promises';
|
|
34
|
+
import * as path from 'path';
|
|
35
|
+
import chalk from 'chalk';
|
|
36
|
+
import { progress } from '../output.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run `app media-upload`.
|
|
40
|
+
*
|
|
41
|
+
* @param {import('../api-client.js').DeSciXApiClient} apiClient
|
|
42
|
+
* @param {{app:string, file:string|string[], prefix?:string, json?:boolean}} options
|
|
43
|
+
* @returns {Promise<{app_id:string, assets:Array<object>, storage:object|null}>}
|
|
44
|
+
*/
|
|
45
|
+
export async function runMediaUpload(apiClient, options) {
|
|
46
|
+
const mime = (await import('mime-types')).default;
|
|
47
|
+
|
|
48
|
+
const appId = options.app;
|
|
49
|
+
const localFiles = Array.isArray(options.file) ? options.file : [options.file];
|
|
50
|
+
// No local normalization: a leading/trailing slash, empty segment, or `.`/`..` segment is a
|
|
51
|
+
// server-owned validity rule (get_asset_upload_token) — pass --prefix through untouched so a
|
|
52
|
+
// bad prefix surfaces as the server's own refusal, never a silently-corrected local guess.
|
|
53
|
+
const subPrefix = options.prefix || '';
|
|
54
|
+
|
|
55
|
+
// Build the upload descriptor list: object path under assets/ = [subPrefix/]basename.
|
|
56
|
+
const fileDescriptors = [];
|
|
57
|
+
for (const localPath of localFiles) {
|
|
58
|
+
const abs = path.resolve(localPath);
|
|
59
|
+
let stat;
|
|
60
|
+
try {
|
|
61
|
+
stat = await fs.stat(abs);
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error(`File not found: ${abs}`);
|
|
64
|
+
}
|
|
65
|
+
if (!stat.isFile()) {
|
|
66
|
+
throw new Error(`Not a file: ${abs}`);
|
|
67
|
+
}
|
|
68
|
+
const base = path.basename(abs);
|
|
69
|
+
const objectPath = subPrefix ? `${subPrefix}/${base}` : base;
|
|
70
|
+
fileDescriptors.push({
|
|
71
|
+
path: objectPath,
|
|
72
|
+
content_type: mime.lookup(abs) || 'application/octet-stream',
|
|
73
|
+
size: stat.size,
|
|
74
|
+
_absolutePath: abs
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
progress(options, chalk.cyan(`\n Media Upload: ${appId} → GCS assets/\n`));
|
|
79
|
+
fileDescriptors.forEach(f => progress(options, chalk.gray(` • ${f.path} (${(f.size / 1024).toFixed(1)}KB, ${f.content_type})`)));
|
|
80
|
+
|
|
81
|
+
// 1. Request a signed-PUT upload token over the API surface. The server owns quota, path
|
|
82
|
+
// validity and caller permission — a refusal here throws with the server's message verbatim
|
|
83
|
+
// and this function does not catch it.
|
|
84
|
+
const tokenResponse = await apiClient.invoke('get_asset_upload_token', {
|
|
85
|
+
app_id: appId,
|
|
86
|
+
files: fileDescriptors.map(f => ({ path: f.path, content_type: f.content_type, size: f.size }))
|
|
87
|
+
});
|
|
88
|
+
const token = tokenResponse.message || tokenResponse;
|
|
89
|
+
const { signed_urls, upload_headers, objects, storage } = token;
|
|
90
|
+
|
|
91
|
+
// 2. PUT each file directly to GCS using its signed URL and the server's exact header bag.
|
|
92
|
+
progress(options, chalk.gray(`\n Uploading ${fileDescriptors.length} file(s)...`));
|
|
93
|
+
const uploaded = [];
|
|
94
|
+
const errors = [];
|
|
95
|
+
for (const f of fileDescriptors) {
|
|
96
|
+
const signedUrl = signed_urls?.[f.path];
|
|
97
|
+
if (!signedUrl) {
|
|
98
|
+
errors.push(`No signed URL for: ${f.path}`);
|
|
99
|
+
progress(options, chalk.red(` x ${f.path}`));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// Never re-derive Content-Type (or the length-range header) locally for the PUT — the
|
|
103
|
+
// server binds them into the v4 signature. A path the server did not ferry a header bag
|
|
104
|
+
// for is an error for that path, naming the server as the thing that is missing it.
|
|
105
|
+
const headers = upload_headers?.[f.path];
|
|
106
|
+
if (!headers) {
|
|
107
|
+
errors.push(
|
|
108
|
+
`No upload_headers for: ${f.path} (get_asset_upload_token response is missing the header ` +
|
|
109
|
+
`bag for this path)`
|
|
110
|
+
);
|
|
111
|
+
progress(options, chalk.red(` x ${f.path}`));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const content = await fs.readFile(f._absolutePath);
|
|
116
|
+
const resp = await fetch(signedUrl, {
|
|
117
|
+
method: 'PUT',
|
|
118
|
+
headers,
|
|
119
|
+
body: content
|
|
120
|
+
});
|
|
121
|
+
if (!resp.ok) {
|
|
122
|
+
errors.push(`Failed ${f.path}: ${resp.status} ${resp.statusText}`);
|
|
123
|
+
progress(options, chalk.red(` x ${f.path}`));
|
|
124
|
+
} else {
|
|
125
|
+
const obj = (objects || []).find(o => o.path === f.path) || {};
|
|
126
|
+
uploaded.push({
|
|
127
|
+
path: f.path,
|
|
128
|
+
gcs_path: obj.gcs_path || null,
|
|
129
|
+
public_url: obj.public_url || null,
|
|
130
|
+
content_type: f.content_type,
|
|
131
|
+
size: f.size
|
|
132
|
+
});
|
|
133
|
+
progress(options, chalk.green(` + ${f.path}`));
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
errors.push(`Error ${f.path}: ${err.message}`);
|
|
137
|
+
progress(options, chalk.red(` x ${f.path}`));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (errors.length > 0) {
|
|
142
|
+
progress(options, chalk.yellow(`\n ${errors.length} error(s):`));
|
|
143
|
+
errors.forEach(e => progress(options, chalk.red(` - ${e}`)));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (uploaded.length === 0) {
|
|
147
|
+
throw new Error('No files uploaded.');
|
|
148
|
+
}
|
|
149
|
+
if (errors.length > 0) {
|
|
150
|
+
// FAIL LOUD on a partial upload: the files that went up are listed above, but a caller that
|
|
151
|
+
// reads only the exit status must not take a half-uploaded set as success.
|
|
152
|
+
throw new Error(`${errors.length} of ${fileDescriptors.length} file(s) failed to upload:\n - ${errors.join('\n - ')}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const result = { app_id: appId, assets: uploaded, storage: storage || null };
|
|
156
|
+
|
|
157
|
+
if (options.json) {
|
|
158
|
+
console.log('\n' + JSON.stringify(result, null, 2) + '\n');
|
|
159
|
+
} else {
|
|
160
|
+
console.log(chalk.green(`\n ✅ Uploaded ${uploaded.length} asset(s).\n`));
|
|
161
|
+
console.log(chalk.cyan(' Asset references (pass the path to your app handler; the service fetches via get_app_asset):'));
|
|
162
|
+
uploaded.forEach(u => {
|
|
163
|
+
console.log(chalk.white(` ${u.path}`));
|
|
164
|
+
console.log(chalk.gray(` public_url: ${u.public_url}`));
|
|
165
|
+
});
|
|
166
|
+
if (result.storage) {
|
|
167
|
+
// projected_bytes is the app's total INCLUDING this upload; used_bytes is the total before it.
|
|
168
|
+
const usedMb = (result.storage.projected_bytes / (1024 * 1024)).toFixed(1);
|
|
169
|
+
const limitMb = (result.storage.limit_bytes / (1024 * 1024)).toFixed(1);
|
|
170
|
+
console.log(chalk.gray(`\n Storage: ${usedMb} MB / ${limitMb} MB used`));
|
|
171
|
+
}
|
|
172
|
+
console.log();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
@@ -19,20 +19,17 @@
|
|
|
19
19
|
export const CANONICAL_KB_SYNC = 'descix kb corpus sync';
|
|
20
20
|
|
|
21
21
|
/** The verb that creates a KB — `kb corpus sync`'s dependency (I4). */
|
|
22
|
-
export const CANONICAL_KB_CREATE = 'descix kb
|
|
22
|
+
export const CANONICAL_KB_CREATE = 'descix app init --kb <kb_name>';
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Every KB-sync surface retired by ws-devplane-cli-kb-surface-single-canonical-sync.
|
|
26
26
|
* `invocation` is what a user types; `parent` is the commander parent it hung off.
|
|
27
27
|
*/
|
|
28
28
|
export const RETIRED_KB_SYNC_SURFACES = [
|
|
29
|
-
{ id: 'sync', parent: 'program', name: 'sync', invocation: 'descix sync'
|
|
30
|
-
{ id: 'sync.kb', parent: 'sync', name: 'kb', invocation: 'descix sync kb'
|
|
31
|
-
{ id: 'kb.chunk', parent: 'kb', name: 'chunk', invocation: 'descix kb chunk'
|
|
32
|
-
{ id: 'kb.sync', parent: 'kb', name: 'sync', invocation: 'descix kb sync'
|
|
33
|
-
// registered:false -- `update` survives for app/site, so this one is a dispatcher branch
|
|
34
|
-
// inside the live `update` command rather than a commander registration of its own.
|
|
35
|
-
{ id: 'update.kb', parent: 'update', name: 'kb', invocation: 'descix update kb', registered: false },
|
|
29
|
+
{ id: 'sync', parent: 'program', name: 'sync', invocation: 'descix sync' },
|
|
30
|
+
{ id: 'sync.kb', parent: 'sync', name: 'kb', invocation: 'descix sync kb' },
|
|
31
|
+
{ id: 'kb.chunk', parent: 'kb', name: 'chunk', invocation: 'descix kb chunk' },
|
|
32
|
+
{ id: 'kb.sync', parent: 'kb', name: 'sync', invocation: 'descix kb sync' },
|
|
36
33
|
];
|
|
37
34
|
|
|
38
35
|
/** Implementations deleted with those surfaces. No exported symbol survives without a caller (I3). */
|
|
@@ -61,24 +58,6 @@ export function retiredKbSyncMessage(invocation) {
|
|
|
61
58
|
);
|
|
62
59
|
}
|
|
63
60
|
|
|
64
|
-
/**
|
|
65
|
-
* Refuse a retired invocation LOUDLY and exit non-zero.
|
|
66
|
-
*
|
|
67
|
-
* MEASURED, not assumed: a `throw` from updateAuto is NOT swallowed — updateAuto's own catch
|
|
68
|
-
* prints the message before rethrowing, so both forms reach the user and exit 1. This exists
|
|
69
|
-
* for two smaller, real reasons: (1) ONE owner for the refusal, so the dispatcher branch and
|
|
70
|
-
* updateAuto's kb branch cannot drift into different wording or different exit codes; and
|
|
71
|
-
* (2) a deliberate retirement is not a crash, so it should not be framed as "✖ Update failed".
|
|
72
|
-
*
|
|
73
|
-
* @param {string} invocation - the retired invocation, e.g. 'descix update kb'
|
|
74
|
-
* @param {(s:string)=>string} red - chalk.red or equivalent
|
|
75
|
-
* @returns {never}
|
|
76
|
-
*/
|
|
77
|
-
export function refuseRetiredKbSync(invocation, red) {
|
|
78
|
-
console.error(red(`\n❌ ${retiredKbSyncMessage(invocation)}\n`));
|
|
79
|
-
process.exit(1);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
61
|
/**
|
|
83
62
|
* Register a retired invocation so it exits NON-ZERO naming the canonical surface.
|
|
84
63
|
* Accepts and ignores any args/options the old surface took, so an old script fails with
|
|
@@ -120,7 +99,6 @@ export function registerRetiredKbSync(parent, name, invocation, red) {
|
|
|
120
99
|
export function registerAllRetiredKbSync(roots, red) {
|
|
121
100
|
const byId = { ...roots };
|
|
122
101
|
for (const s of RETIRED_KB_SYNC_SURFACES) {
|
|
123
|
-
if (!s.registered) continue;
|
|
124
102
|
const parent = byId[s.parent];
|
|
125
103
|
if (!parent) {
|
|
126
104
|
throw new Error(
|
package/lib/commands/status.js
CHANGED
|
@@ -206,15 +206,6 @@ export async function runStatus(options = {}) {
|
|
|
206
206
|
console.log(`Service Key: ${chalk.gray('None')} (dev-overrides.json)`);
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
// Check for MCP rules
|
|
210
|
-
const rulesPath = path.join(checkDir, '.cursor', 'rules', 'descix_mcp.mdc');
|
|
211
|
-
try {
|
|
212
|
-
await fs.access(rulesPath);
|
|
213
|
-
console.log(`MCP Rules: ${chalk.green('Deployed')} (.cursor/rules/descix_mcp.mdc)`);
|
|
214
|
-
} catch {
|
|
215
|
-
console.log(`MCP Rules: ${chalk.gray('Not Deployed')}`);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
209
|
// Check gcloud
|
|
219
210
|
const gcloudStatus = await checkGcloud();
|
|
220
211
|
if (gcloudStatus.installed) {
|
package/lib/output.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI output — ONE OWNER of the "human progress vs. --json data channel" split.
|
|
3
|
+
*
|
|
4
|
+
* Relocated from bin/descix.js (WS-MEDIA-QUOTA) so lib/commands/* modules can share it
|
|
5
|
+
* without bin/ -> lib/ layering going backwards. This is a move, not a duplication: bin/descix.js
|
|
6
|
+
* imports `progress` from here instead of defining its own copy.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Emit human-facing PROGRESS for a command that can also emit JSON.
|
|
11
|
+
*
|
|
12
|
+
* When `--json` is set, stdout is a DATA CHANNEL: it must contain the JSON document and
|
|
13
|
+
* nothing else, or every scripted consumer breaks. Progress then goes to stderr, where a
|
|
14
|
+
* human piping stdout still sees it. Without `--json`, stdout is the human channel and
|
|
15
|
+
* progress belongs there.
|
|
16
|
+
*
|
|
17
|
+
* ONE OWNER of that decision. Before this existed, each --json command re-decided it by
|
|
18
|
+
* calling console.log directly, and both of them got it wrong: `mcp execute --json` prefixed
|
|
19
|
+
* the document with "Executing <tool>..." and `app media-upload --json` prefixed it with a
|
|
20
|
+
* header, a per-file listing and a per-file +/x line. Both exited 0, so a consumer saw a
|
|
21
|
+
* success code and an unparseable stream. Measured 2026-08-21/22 (seat BEAST): two seats had
|
|
22
|
+
* independently hand-rolled prefix-stripping workarounds (`tail -n +2`, `raw.find('[')`)
|
|
23
|
+
* rather than reporting it.
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} options - the command's parsed options (read for .json)
|
|
26
|
+
* @param {...any} args - passed through to console.log / console.error
|
|
27
|
+
*/
|
|
28
|
+
export function progress(options, ...args) {
|
|
29
|
+
if (options && options.json) console.error(...args);
|
|
30
|
+
else console.log(...args);
|
|
31
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK's agent assets (instructions, guides, reference) ship inside this package at
|
|
3
|
+
* agent-assets/. `pullSdkAssets` copies them into a workspace at .descix/sdk-assets/ so a local AI
|
|
4
|
+
* agent can read them. A missing source is a broken install and throws; it is never skipped.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'fs/promises';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
/** agent-assets/ at the package root, one level above lib/. */
|
|
11
|
+
export const SDK_ASSETS_SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'agent-assets');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Copy the package's agent-assets/ to <workspaceRoot>/.descix/sdk-assets/.
|
|
15
|
+
* @param {string} workspaceRoot
|
|
16
|
+
* @returns {Promise<string>} the target directory
|
|
17
|
+
* @throws when the package's agent-assets/ is absent or the copy fails
|
|
18
|
+
*/
|
|
19
|
+
export async function pullSdkAssets(workspaceRoot) {
|
|
20
|
+
const target = path.join(workspaceRoot, '.descix', 'sdk-assets');
|
|
21
|
+
try {
|
|
22
|
+
await fs.access(SDK_ASSETS_SOURCE);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`SDK agent assets are missing from this install (${SDK_ASSETS_SOURCE}). Reinstall @descix/cli.`);
|
|
25
|
+
}
|
|
26
|
+
await fs.cp(SDK_ASSETS_SOURCE, target, { recursive: true });
|
|
27
|
+
return target;
|
|
28
|
+
}
|