@botdocs/cli 0.10.2 → 0.11.0
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 +7 -5
- package/bin/botdocs.cjs +78 -0
- package/dist/commands/check-updates.d.ts +22 -0
- package/dist/commands/check-updates.js +73 -18
- package/dist/commands/edit.js +10 -2
- package/dist/commands/ingest.d.ts +20 -0
- package/dist/commands/ingest.js +264 -11
- package/dist/commands/init.d.ts +24 -0
- package/dist/commands/init.js +43 -6
- package/dist/commands/install.js +146 -5
- package/dist/commands/list.js +62 -0
- package/dist/commands/login.js +56 -2
- package/dist/commands/publish.d.ts +30 -3
- package/dist/commands/publish.js +353 -40
- package/dist/commands/sync.js +252 -40
- package/dist/commands/uninstall.js +12 -0
- package/dist/commands/validate.js +82 -8
- package/dist/index.js +152 -6
- package/dist/lib/api.d.ts +72 -2
- package/dist/lib/api.js +204 -11
- package/dist/lib/auto-detect.js +70 -30
- package/dist/lib/config.d.ts +15 -0
- package/dist/lib/config.js +83 -2
- package/dist/lib/ingest-session-client.d.ts +93 -0
- package/dist/lib/ingest-session-client.js +217 -0
- package/dist/lib/lockfile.d.ts +13 -0
- package/dist/lib/manifest.d.ts +12 -0
- package/dist/lib/manifest.js +29 -2
- package/dist/lib/node-preflight.d.ts +20 -0
- package/dist/lib/node-preflight.js +11 -0
- package/dist/lib/skill-caps.d.ts +17 -0
- package/dist/lib/skill-caps.js +19 -0
- package/package.json +3 -2
package/dist/commands/install.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { ApiError, apiFetch, fetchRawContent } from '../lib/api.js';
|
|
4
|
+
import { ApiError, apiFetch, fetchRawContent, friendlyApiErrorDetail } from '../lib/api.js';
|
|
5
5
|
import { detectDestination } from '../lib/auto-detect.js';
|
|
6
6
|
import { SUPPORTED_ECOSYSTEMS } from '../lib/canonical.js';
|
|
7
7
|
import { convertSkillToEcosystem, parseFrontmatter, stripFrontmatter, } from '../lib/convert.js';
|
|
@@ -96,10 +96,19 @@ async function installSkill(ref, manifest, options, scope) {
|
|
|
96
96
|
if (detection.kind === 'skip')
|
|
97
97
|
continue;
|
|
98
98
|
if (detection.kind === 'manual') {
|
|
99
|
-
// Print for user paste (ChatGPT case). Suppressed under --json so
|
|
99
|
+
// Print for user paste (ChatGPT/Gemini case). Suppressed under --json so
|
|
100
|
+
// output stays parseable. We also surface the per-ecosystem instruction
|
|
101
|
+
// from MANUAL_INSTRUCTIONS so a non-dev knows what to DO with the dumped
|
|
102
|
+
// text (paste into Custom GPT, add to GEMINI.md, etc.) rather than
|
|
103
|
+
// seeing a wall of markdown followed by "✓ Installed".
|
|
100
104
|
if (!options.json) {
|
|
101
105
|
const content = await fetchRawContent(file.rawUrl);
|
|
102
|
-
|
|
106
|
+
const ecosystem = file.filename.split('/')[0];
|
|
107
|
+
const instr = MANUAL_INSTRUCTIONS[ecosystem];
|
|
108
|
+
console.log(`\n Manual paste required for ${ecosystem}:${instr ? ` ${instr}` : ''}`);
|
|
109
|
+
console.log(' ---');
|
|
110
|
+
console.log(content);
|
|
111
|
+
console.log(' ---\n');
|
|
103
112
|
}
|
|
104
113
|
// A manual paste prompt counts as "we surfaced this file to the user",
|
|
105
114
|
// so don't trigger the no-installable-files warning below.
|
|
@@ -340,8 +349,97 @@ async function crossInstall(refStr, manifest, options, scope) {
|
|
|
340
349
|
console.log('');
|
|
341
350
|
await syncLibrary();
|
|
342
351
|
}
|
|
352
|
+
/** Compare a prior install entry against an incoming SKILL manifest. Returns
|
|
353
|
+
* true only when nothing has drifted: same version, same set of manifest
|
|
354
|
+
* filenames, and every tracked file still on disk with a matching
|
|
355
|
+
* fingerprint. Any local edit (fingerprint mismatch), missing file, or
|
|
356
|
+
* upstream-added file makes the install non-idempotent and we fall through
|
|
357
|
+
* to the normal install path. */
|
|
358
|
+
function isInstallUpToDate(prior, manifest) {
|
|
359
|
+
if (prior.version !== manifest.version)
|
|
360
|
+
return false;
|
|
361
|
+
// Resolve each manifest filename to the destination the prior install
|
|
362
|
+
// recorded for it. If the manifest adds a file the prior install didn't
|
|
363
|
+
// have (or vice versa), we're not up-to-date.
|
|
364
|
+
const priorBySrc = new Map(prior.files.map((f) => [f.src, f]));
|
|
365
|
+
for (const manifestFile of manifest.files) {
|
|
366
|
+
const tracked = priorBySrc.get(manifestFile.filename);
|
|
367
|
+
if (!tracked)
|
|
368
|
+
return false;
|
|
369
|
+
if (!fs.existsSync(tracked.dest))
|
|
370
|
+
return false;
|
|
371
|
+
if (fingerprintFile(tracked.dest) !== tracked.fingerprint)
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
// Allow prior to have MORE files than the manifest (skipped detections)
|
|
375
|
+
// only when the extras aren't in the manifest. The loop above already
|
|
376
|
+
// confirmed every manifest file is tracked; check there are no manifest
|
|
377
|
+
// entries the prior install dropped.
|
|
378
|
+
if (manifest.files.length !== prior.files.filter((f) => priorBySrc.has(f.src)).length) {
|
|
379
|
+
// Shouldn't be possible given the map construction, but defend against
|
|
380
|
+
// future schema drift.
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
/** Compute the Levenshtein edit distance between two short strings. Used by
|
|
386
|
+
* the "did you mean?" suggester to filter search hits that are too far from
|
|
387
|
+
* the user's typo to be useful. Implementation is the textbook two-row DP —
|
|
388
|
+
* O(n*m) time, O(min(n,m)) space. Both inputs are slug-sized (≤ ~64 chars),
|
|
389
|
+
* so the constant factor doesn't matter. */
|
|
390
|
+
function levenshtein(a, b) {
|
|
391
|
+
if (a === b)
|
|
392
|
+
return 0;
|
|
393
|
+
if (a.length === 0)
|
|
394
|
+
return b.length;
|
|
395
|
+
if (b.length === 0)
|
|
396
|
+
return a.length;
|
|
397
|
+
let prev = new Array(b.length + 1);
|
|
398
|
+
let curr = new Array(b.length + 1);
|
|
399
|
+
for (let j = 0; j <= b.length; j++)
|
|
400
|
+
prev[j] = j;
|
|
401
|
+
for (let i = 1; i <= a.length; i++) {
|
|
402
|
+
curr[0] = i;
|
|
403
|
+
for (let j = 1; j <= b.length; j++) {
|
|
404
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
405
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
406
|
+
}
|
|
407
|
+
[prev, curr] = [curr, prev];
|
|
408
|
+
}
|
|
409
|
+
return prev[b.length];
|
|
410
|
+
}
|
|
411
|
+
/** On a 404, query the search endpoint for the user's slug fragment and
|
|
412
|
+
* return the top result as a `@user/slug` string when its slug is within
|
|
413
|
+
* Levenshtein distance 2 of the requested slug. Returns null when the
|
|
414
|
+
* search returns no results, when the top result is too far, or when
|
|
415
|
+
* `requestedRef` already equals the top match (defensive). Never throws —
|
|
416
|
+
* callers wrap in catch and treat any failure as "no suggestion". */
|
|
417
|
+
async function suggestClosestRef(requestedSlug, requestedRef) {
|
|
418
|
+
const encoded = encodeURIComponent(requestedSlug);
|
|
419
|
+
const resp = await apiFetch(`/api/search?q=${encoded}`);
|
|
420
|
+
const top = resp.results[0];
|
|
421
|
+
if (!top)
|
|
422
|
+
return null;
|
|
423
|
+
if (levenshtein(top.slug.toLowerCase(), requestedSlug.toLowerCase()) > 2)
|
|
424
|
+
return null;
|
|
425
|
+
const candidate = `@${top.author}/${top.slug}`;
|
|
426
|
+
if (candidate === requestedRef)
|
|
427
|
+
return null;
|
|
428
|
+
return candidate;
|
|
429
|
+
}
|
|
343
430
|
export async function install(rawRef, options) {
|
|
344
|
-
|
|
431
|
+
let ref;
|
|
432
|
+
try {
|
|
433
|
+
ref = parseRef(rawRef);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// parseRef throws `Error: Invalid ref: foo (expected @user/slug)` on
|
|
437
|
+
// malformed input — uncaught it stack-trips through commander. Catch it
|
|
438
|
+
// here and surface the same `✗ ` formatting other CLI errors use so a
|
|
439
|
+
// typo like `botdocs install foo` lands at one clean line, not a stack.
|
|
440
|
+
console.error(`\n ✗ Invalid ref: ${rawRef} — expected @user/slug (e.g. @yourname/my-skill)\n`);
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
345
443
|
const refStr = `@${ref.username}/${ref.slug}`;
|
|
346
444
|
if (options.clean) {
|
|
347
445
|
const lf = loadLockfile();
|
|
@@ -371,11 +469,49 @@ export async function install(rawRef, options) {
|
|
|
371
469
|
}
|
|
372
470
|
catch (err) {
|
|
373
471
|
if (err instanceof ApiError && err.status === 404) {
|
|
374
|
-
|
|
472
|
+
// 404 dead-ends are common when users mistype a slug. Ask the search
|
|
473
|
+
// endpoint for the slug-fragment and surface the top close match
|
|
474
|
+
// (Levenshtein distance ≤ 2) so the user can quickly correct.
|
|
475
|
+
// Network errors from the suggestion lookup are swallowed — the 404
|
|
476
|
+
// itself is the primary signal and we shouldn't gate it on the
|
|
477
|
+
// suggestion call succeeding.
|
|
478
|
+
const suggestion = await suggestClosestRef(ref.slug, refStr).catch(() => null);
|
|
479
|
+
const suffix = suggestion ? `\n Did you mean: ${suggestion}?\n` : '\n';
|
|
480
|
+
console.error(`\n ✗ Skill or bundle not found: ${refStr}${suffix}`);
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
483
|
+
// 410 Gone = author soft-deleted the skill upstream. Distinct from 404
|
|
484
|
+
// ("never existed") so users know they had it installed and can clean up.
|
|
485
|
+
if (err instanceof ApiError && err.status === 410) {
|
|
486
|
+
console.error(`\n ⌀ ${refStr}: removed by author — run \`botdocs uninstall ${refStr}\` to clean up local files.\n`);
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|
|
489
|
+
// Generic 403/429/5xx — route through the shared friendly-detail helper
|
|
490
|
+
// so the error vocabulary stays consistent across commands instead of
|
|
491
|
+
// leaking a raw ApiError stack to stderr.
|
|
492
|
+
if (err instanceof ApiError) {
|
|
493
|
+
console.error(`\n ✗ ${refStr}: ${friendlyApiErrorDetail(err, refStr)}\n`);
|
|
375
494
|
process.exit(1);
|
|
376
495
|
}
|
|
377
496
|
throw err;
|
|
378
497
|
}
|
|
498
|
+
// Already-at-version short-circuit. Re-running install on a skill at the
|
|
499
|
+
// current version with all file fingerprints matching does nothing visible
|
|
500
|
+
// today — the user can't tell whether anything changed. Compare the
|
|
501
|
+
// manifest version + each tracked file's fingerprint to the lockfile entry;
|
|
502
|
+
// when nothing has drifted, print a clear no-op line and skip the work.
|
|
503
|
+
// Skipped under --json and --clean (both want the full path), and only
|
|
504
|
+
// applies when a prior install exists. Bundles re-resolve per-skill so
|
|
505
|
+
// the short-circuit fires at the skill level inside the loop below.
|
|
506
|
+
if (!options.clean && !options.json && !options.to && manifest.type === 'SKILL') {
|
|
507
|
+
const lf = loadLockfile();
|
|
508
|
+
const prior = lf.installs.find((i) => i.ref === refStr);
|
|
509
|
+
if (prior && isInstallUpToDate(prior, manifest)) {
|
|
510
|
+
console.log(`\n ✓ ${refStr} already at v${manifest.version} (no changes)\n`);
|
|
511
|
+
await syncLibrary();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
379
515
|
// `--to`: cross-install into a different ecosystem (deterministic, no LLM).
|
|
380
516
|
if (options.to) {
|
|
381
517
|
await crossInstall(refStr, manifest, options, ref.username);
|
|
@@ -417,6 +553,11 @@ export async function install(rawRef, options) {
|
|
|
417
553
|
continue;
|
|
418
554
|
console.log(` ${entry.ref}: ${entry.files.length} file(s)`);
|
|
419
555
|
}
|
|
556
|
+
// Dim sync hint — only on the success path, not on errors. Suppressed when
|
|
557
|
+
// --json is set (JSON mode is for scripting and shouldn't carry banner
|
|
558
|
+
// noise). ANSI dim (\x1b[2m) degrades to plain text on terminals that
|
|
559
|
+
// don't support it; harmless when redirected to a file.
|
|
560
|
+
console.log('\x1b[2m → Updates? Run `botdocs sync` (or `botdocs install-instructions --shell-hook` for auto-notify)\x1b[0m');
|
|
420
561
|
console.log('');
|
|
421
562
|
await syncLibrary();
|
|
422
563
|
}
|
package/dist/commands/list.js
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
import { loadLockfile } from '../lib/lockfile.js';
|
|
2
|
+
import { fetchUpdates } from './check-updates.js';
|
|
3
|
+
/** Filter the lockfile down to the subset that has an available update or has
|
|
4
|
+
* been removed upstream, per the cached check-updates result. Returns the
|
|
5
|
+
* (refs, result) tuple so the caller can render a structured summary that
|
|
6
|
+
* distinguishes "outdated" from "gone". */
|
|
7
|
+
function filterOutdated(installs, result) {
|
|
8
|
+
const updateMap = new Map();
|
|
9
|
+
for (const u of result.updates)
|
|
10
|
+
updateMap.set(u.ref, { from: u.from, to: u.to });
|
|
11
|
+
const removedRefs = new Set(result.removed.map((r) => r.ref));
|
|
12
|
+
const outdated = installs.filter((i) => updateMap.has(i.ref) || removedRefs.has(i.ref));
|
|
13
|
+
return { outdated, removedRefs, updateMap };
|
|
14
|
+
}
|
|
2
15
|
export async function list(options) {
|
|
3
16
|
const lf = loadLockfile();
|
|
17
|
+
if (options.outdated) {
|
|
18
|
+
// Empty lockfile short-circuit — no point hitting the server when there's
|
|
19
|
+
// nothing to compare against.
|
|
20
|
+
if (lf.installs.length === 0) {
|
|
21
|
+
if (options.json) {
|
|
22
|
+
console.log(JSON.stringify({ outdated: [], removed: [] }));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
console.log('\n nothing installed yet — try `botdocs install @user/slug`\n');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const result = await fetchUpdates();
|
|
29
|
+
const { outdated, removedRefs, updateMap } = filterOutdated(lf.installs, result);
|
|
30
|
+
if (options.json) {
|
|
31
|
+
// Emit a structured shape: every outdated install with its delta + a
|
|
32
|
+
// boolean flag for the removed-upstream case. Easier for scripts than
|
|
33
|
+
// re-correlating against the raw check-updates output.
|
|
34
|
+
const shaped = outdated.map((i) => {
|
|
35
|
+
const update = updateMap.get(i.ref);
|
|
36
|
+
return {
|
|
37
|
+
ref: i.ref,
|
|
38
|
+
type: i.type,
|
|
39
|
+
version: i.version,
|
|
40
|
+
latest: update?.to ?? null,
|
|
41
|
+
removed: removedRefs.has(i.ref),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
console.log(JSON.stringify({ outdated: shaped }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (outdated.length === 0) {
|
|
48
|
+
console.log('\n Everything is up to date.\n');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
console.log('');
|
|
52
|
+
for (const entry of outdated) {
|
|
53
|
+
const update = updateMap.get(entry.ref);
|
|
54
|
+
if (update) {
|
|
55
|
+
console.log(` ▸ ${entry.ref}: ${update.from} → ${update.to}`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
// Removed upstream — surface the same ⌀ glyph check-updates uses so
|
|
59
|
+
// users learn one visual language for "gone".
|
|
60
|
+
console.log(` ⌀ ${entry.ref}: removed upstream`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
console.log('');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
4
66
|
if (options.json) {
|
|
5
67
|
console.log(JSON.stringify({ installs: lf.installs }));
|
|
6
68
|
return;
|
package/dist/commands/login.js
CHANGED
|
@@ -75,6 +75,30 @@ async function initLoginState(baseUrl) {
|
|
|
75
75
|
if (!initRes.ok) {
|
|
76
76
|
throw new Error(`Failed to start login (HTTP ${initRes.status}).`);
|
|
77
77
|
}
|
|
78
|
+
// P3 #14 (DOCUMENTATION-ONLY trade-off, not a code fix in this PR):
|
|
79
|
+
//
|
|
80
|
+
// The `state` value rides in the query string on the browser-side
|
|
81
|
+
// authorize URL. Query-string parameters land in:
|
|
82
|
+
// - the browser's address bar (visible to anyone over the user's
|
|
83
|
+
// shoulder + saved in browser history)
|
|
84
|
+
// - any Referer header on links the user clicks from /cli-auth
|
|
85
|
+
// - server-side access logs at every proxy between the user and the
|
|
86
|
+
// Vercel edge
|
|
87
|
+
//
|
|
88
|
+
// The cli-auth state IS effectively a single-use, time-boxed (10 min)
|
|
89
|
+
// bearer for the device-grant flow. Leaking it would let an observer
|
|
90
|
+
// race the user to call /grant. We accept this exposure today because:
|
|
91
|
+
// 1. The /cli-auth page strips Referer on the click that ultimately
|
|
92
|
+
// lands the grant (the button submits a POST, not an <a href>),
|
|
93
|
+
// so the worst leak is the address bar + browser history.
|
|
94
|
+
// 2. The state is single-use and short-lived; replay is bounded.
|
|
95
|
+
// 3. The CLI binds the state to the polling channel — an attacker
|
|
96
|
+
// who steals it would need to win a poll race with our own CLI.
|
|
97
|
+
//
|
|
98
|
+
// A future revision should switch this to a POST-based handoff with the
|
|
99
|
+
// state carried in the URL hash fragment (#state=...), which browsers
|
|
100
|
+
// never send to the server and never write to history. That refactor is
|
|
101
|
+
// out of scope for the P3 bucket — tracked as a separate item.
|
|
78
102
|
return {
|
|
79
103
|
state,
|
|
80
104
|
authUrl: `${baseUrl}/cli-auth?state=${state}`,
|
|
@@ -210,14 +234,44 @@ async function loginPlainInteractive(syncLibrary) {
|
|
|
210
234
|
printSyncLibraryHint(syncLibrary);
|
|
211
235
|
}
|
|
212
236
|
/** Polls the server with exponential backoff. Returns the granted payload on
|
|
213
|
-
* success or null if the loop ran out of budget (matching the server's TTL).
|
|
237
|
+
* success or null if the loop ran out of budget (matching the server's TTL).
|
|
238
|
+
*
|
|
239
|
+
* Network errors (DNS hiccup, WiFi blip, server briefly unreachable) are
|
|
240
|
+
* caught and treated the same as transient HTTP 5xx: we keep polling. An
|
|
241
|
+
* uncaught throw here would crash Ink mid-render and leave the terminal in
|
|
242
|
+
* raw mode. We surface a single dimmed "retrying…" hint after three
|
|
243
|
+
* consecutive failures so the user knows their network is the bottleneck
|
|
244
|
+
* rather than assuming the CLI hung. */
|
|
214
245
|
async function pollUntilGranted(baseUrl, state) {
|
|
215
246
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
216
247
|
let delay = POLL_INITIAL_DELAY_MS;
|
|
248
|
+
let consecutiveFailures = 0;
|
|
217
249
|
while (Date.now() < deadline) {
|
|
218
250
|
await sleep(delay);
|
|
219
251
|
delay = Math.min(delay * POLL_BACKOFF_FACTOR, POLL_MAX_DELAY_MS);
|
|
220
|
-
|
|
252
|
+
let res;
|
|
253
|
+
try {
|
|
254
|
+
// P3 #14: the poll endpoint also carries `state` in the query string.
|
|
255
|
+
// See the comment in `initLoginState` for the full trade-off rationale
|
|
256
|
+
// and the planned migration to a hash-fragment + POST handoff.
|
|
257
|
+
res = await fetch(`${baseUrl}/api/cli/auth/poll?state=${encodeURIComponent(state)}`, { headers: { Accept: 'application/json' } });
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Network-level failure — same disposition as a 5xx: keep polling
|
|
261
|
+
// within the 10-minute budget. Note we intentionally don't surface a
|
|
262
|
+
// visible error to Ink to avoid mid-render churn; the deadline check
|
|
263
|
+
// remains the source of truth for "give up and exit".
|
|
264
|
+
consecutiveFailures += 1;
|
|
265
|
+
if (consecutiveFailures >= 3 && process.stderr.isTTY) {
|
|
266
|
+
// Dimmed retry hint goes to stderr so it doesn't disturb Ink's
|
|
267
|
+
// stdout-rendered frame buffer. Only printed once per streak.
|
|
268
|
+
if (consecutiveFailures === 3) {
|
|
269
|
+
process.stderr.write('\n (network hiccup, retrying…)\n');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
consecutiveFailures = 0;
|
|
221
275
|
if (res.status === 410) {
|
|
222
276
|
return null;
|
|
223
277
|
}
|
|
@@ -6,10 +6,22 @@ interface PublishOptions {
|
|
|
6
6
|
license?: string;
|
|
7
7
|
json?: boolean;
|
|
8
8
|
noCompile?: boolean;
|
|
9
|
-
/** Skip
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
/** Skip the pre-POST y/N confirmation prompt. First-time authors don't
|
|
10
|
+
* always realize `publish` is immediate, so the path-form publish asks
|
|
11
|
+
* "Publish to public registry as @<user>/<slug>? [y/N]" by default.
|
|
12
|
+
* `--yes` bypasses it for scripted runs; `--json` skips it implicitly
|
|
13
|
+
* because JSON mode is for non-interactive consumers. */
|
|
12
14
|
yes?: boolean;
|
|
15
|
+
/** Build the payload, print what would be published, exit 0 without POSTing.
|
|
16
|
+
* Lets authors preview title/description/file list + total size before they
|
|
17
|
+
* commit to publishing. Skipped entirely on ref-form publish (toggling a
|
|
18
|
+
* draft → published flag is already cheap and reversible via `unpublish`). */
|
|
19
|
+
dryRun?: boolean;
|
|
20
|
+
}
|
|
21
|
+
interface FileEntry {
|
|
22
|
+
filename: string;
|
|
23
|
+
content: string;
|
|
24
|
+
sortOrder: number;
|
|
13
25
|
}
|
|
14
26
|
export declare function publish(source: string, options: PublishOptions): Promise<void>;
|
|
15
27
|
/**
|
|
@@ -37,3 +49,18 @@ declare function publishRef(rawRef: string, options: PublishOptions): Promise<vo
|
|
|
37
49
|
*/
|
|
38
50
|
declare function handlePublishToggleError(err: unknown, refLabel: string, options: PublishOptions): void;
|
|
39
51
|
export { publishRef, handlePublishToggleError };
|
|
52
|
+
/**
|
|
53
|
+
* Recursively collect publishable markdown files under `currentDir`.
|
|
54
|
+
*
|
|
55
|
+
* Symlinks are skipped (with a warning) to prevent local file exfiltration:
|
|
56
|
+
* without this check a directory containing a symlink to e.g. `~/.ssh/id_rsa`
|
|
57
|
+
* would silently upload that file's contents as a public skill. We use
|
|
58
|
+
* `withFileTypes: true` so the `Dirent` reports the entry's own type (NOT
|
|
59
|
+
* the target's), then gate on `isFile()` / `isDirectory()` explicitly so
|
|
60
|
+
* anything else (symlinks, sockets, FIFOs, character/block devices) is
|
|
61
|
+
* dropped. Mirrors `walkAll` in `src/lib/ingest-discover.ts`.
|
|
62
|
+
*
|
|
63
|
+
* Exported only for unit testing — production callers should go through
|
|
64
|
+
* `collectFromDirectory`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function walkDirectory(rootDir: string, currentDir: string, out: FileEntry[]): void;
|