sella-cli 0.5.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.
@@ -0,0 +1,174 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ /**
4
+ * `sella publish` (CLI sprint C7): the supplier path no competitor CLI has — a CSV → live listing
5
+ * without opening the browser.
6
+ *
7
+ * sella publish init <file.csv> scaffold sella-dataset.json + a local structural pre-check
8
+ * sella publish push [--card …] upload via the SAME publish route the web dashboard uses
9
+ *
10
+ * The push authenticates with the machine's stored `sk_live_` key against POST /api/datasets
11
+ * (dual-auth per lib/withPublisherAuth.ts), so the CLI and dashboard supplier flows share one core.
12
+ * Zero runtime deps: the pre-check is a light structural preview (not the server-side ADC run) so a
13
+ * publisher sees obvious problems — ragged rows, empty columns — before spending a round-trip.
14
+ */
15
+ export const CARD_FILENAME = 'sella-dataset.json';
16
+ /**
17
+ * Light, dependency-free structural preview of a CSV. NOT the server-side ADC evaluation — it just
18
+ * surfaces obvious problems (no rows, ragged rows, duplicate/blank headers, empty columns) so the
19
+ * publisher can fix them before pushing. Naive comma-split: quoted commas aren't parsed, which is
20
+ * fine for a heads-up preview.
21
+ */
22
+ export function precheckCsv(text) {
23
+ const warnings = [];
24
+ const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
25
+ if (lines.length === 0) {
26
+ return { columns: [], rows: 0, warnings: ['File is empty.'], emptyCells: 0 };
27
+ }
28
+ const columns = lines[0].split(',').map((c) => c.trim());
29
+ if (columns.some((c) => c === ''))
30
+ warnings.push('One or more header cells are blank.');
31
+ const dupes = columns.filter((c, i) => c !== '' && columns.indexOf(c) !== i);
32
+ if (dupes.length)
33
+ warnings.push(`Duplicate header(s): ${[...new Set(dupes)].join(', ')}.`);
34
+ const dataLines = lines.slice(1);
35
+ if (dataLines.length === 0)
36
+ warnings.push('No data rows — only a header was found.');
37
+ let emptyCells = 0;
38
+ let ragged = 0;
39
+ const nonEmptyPerColumn = new Array(columns.length).fill(0);
40
+ for (const line of dataLines) {
41
+ const cells = line.split(',');
42
+ if (cells.length !== columns.length)
43
+ ragged += 1;
44
+ for (let i = 0; i < columns.length; i += 1) {
45
+ const v = (cells[i] ?? '').trim();
46
+ if (v === '')
47
+ emptyCells += 1;
48
+ else
49
+ nonEmptyPerColumn[i] += 1;
50
+ }
51
+ }
52
+ if (ragged)
53
+ warnings.push(`${ragged} row(s) have a different column count than the header.`);
54
+ const emptyColumns = columns.filter((_, i) => dataLines.length > 0 && nonEmptyPerColumn[i] === 0);
55
+ if (emptyColumns.length)
56
+ warnings.push(`Column(s) with no values: ${emptyColumns.join(', ')}.`);
57
+ return { columns, rows: dataLines.length, emptyCells, warnings };
58
+ }
59
+ function titleFromFilename(csvPath) {
60
+ const base = path.basename(csvPath).replace(/\.[^.]+$/, '');
61
+ const words = base.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
62
+ return words ? words.replace(/\b\w/g, (c) => c.toUpperCase()).slice(0, 100) : 'Untitled dataset';
63
+ }
64
+ /**
65
+ * Scaffold a dataset card next to the CSV and run the pre-check. Does NOT overwrite an existing
66
+ * card (wroteNew=false) so re-running `init` is safe. Throws only if the CSV can't be read.
67
+ */
68
+ export function scaffoldCard(csvPath) {
69
+ const text = fs.readFileSync(csvPath, 'utf8'); // throws a clear ENOENT the caller surfaces
70
+ const precheck = precheckCsv(text);
71
+ const cardPath = path.join(path.dirname(path.resolve(csvPath)), CARD_FILENAME);
72
+ const card = {
73
+ $schema: 'sella-dataset/v1',
74
+ title: titleFromFilename(csvPath),
75
+ description: '',
76
+ category: 'Other',
77
+ tags: [],
78
+ tier: 'standard',
79
+ priceUSDC: 0,
80
+ format: 'csv',
81
+ licenseType: 'cc-by-4.0',
82
+ csv: path.basename(csvPath),
83
+ };
84
+ if (fs.existsSync(cardPath)) {
85
+ return { cardPath, card: JSON.parse(fs.readFileSync(cardPath, 'utf8')), precheck, wroteNew: false };
86
+ }
87
+ fs.writeFileSync(cardPath, JSON.stringify(card, null, 2) + '\n');
88
+ return { cardPath, card, precheck, wroteNew: true };
89
+ }
90
+ const REQUIRED_CARD_FIELDS = ['title', 'description', 'category', 'tier', 'format', 'licenseType', 'csv'];
91
+ const TERMINAL_STATUSES = new Set(['live', 'manual_review']);
92
+ /** Read the card + its CSV, create the dataset via the shared publish route, and poll for status. */
93
+ export async function pushDataset(opts) {
94
+ const fetchImpl = opts.fetchImpl || fetch;
95
+ const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
96
+ const maxPolls = opts.maxPolls ?? 6;
97
+ const pollIntervalMs = opts.pollIntervalMs ?? 1500;
98
+ let card;
99
+ try {
100
+ card = JSON.parse(fs.readFileSync(opts.cardPath, 'utf8'));
101
+ }
102
+ catch {
103
+ return { ok: false, error: `Could not read the dataset card at ${opts.cardPath}. Run \`sella publish init <file.csv>\` first.` };
104
+ }
105
+ const missing = REQUIRED_CARD_FIELDS.filter((f) => card[f] === undefined || card[f] === '' || card[f] === null);
106
+ if (missing.length) {
107
+ return { ok: false, error: `Card is missing required field(s): ${missing.join(', ')}. Edit ${path.basename(opts.cardPath)} and retry.` };
108
+ }
109
+ if (!card.description || String(card.description).trim() === '') {
110
+ return { ok: false, error: `Add a "description" to ${path.basename(opts.cardPath)} before publishing.` };
111
+ }
112
+ const csvPath = path.isAbsolute(card.csv) ? card.csv : path.join(path.dirname(opts.cardPath), card.csv);
113
+ let content;
114
+ try {
115
+ content = fs.readFileSync(csvPath, 'utf8');
116
+ }
117
+ catch {
118
+ return { ok: false, error: `Could not read the CSV referenced by the card: ${csvPath}` };
119
+ }
120
+ const body = {
121
+ title: card.title,
122
+ description: card.description,
123
+ category: card.category,
124
+ tags: Array.isArray(card.tags) ? card.tags : [],
125
+ tier: card.tier,
126
+ priceUSDC: typeof card.priceUSDC === 'number' ? card.priceUSDC : Number(card.priceUSDC ?? 0),
127
+ format: card.format,
128
+ licenseType: card.licenseType,
129
+ ...(card.licenseDetails ? { licenseDetails: card.licenseDetails } : {}),
130
+ content,
131
+ };
132
+ let created;
133
+ try {
134
+ const res = await fetchImpl(`${opts.origin}/api/datasets`, {
135
+ method: 'POST',
136
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${opts.apiKey}` },
137
+ body: JSON.stringify(body),
138
+ });
139
+ const json = await res.json().catch(() => ({}));
140
+ if (res.status === 401)
141
+ return { ok: false, error: 'Not authenticated — run `sella pair` to (re)authenticate this machine.' };
142
+ if (res.status === 422)
143
+ return { ok: false, error: 'The listing was rejected by validation.', fieldErrors: json.fieldErrors };
144
+ if (!res.ok)
145
+ return { ok: false, error: `Publish endpoint responded ${res.status}.` };
146
+ created = json.dataset;
147
+ }
148
+ catch (err) {
149
+ return { ok: false, error: `Could not reach ${opts.origin}: ${err instanceof Error ? err.message : 'network error'}` };
150
+ }
151
+ const datasetId = created?.id ? String(created.id) : undefined;
152
+ if (!datasetId)
153
+ return { ok: false, error: 'The server accepted the upload but returned no dataset id.' };
154
+ const listingUrl = `${opts.origin}/datasets/${datasetId}`;
155
+ // Creation kicks off async scoring (processing → manual_review | live). Poll a bounded number of
156
+ // times; report the current status either way so a headless run never hangs.
157
+ let status = String(created.publishStatus || 'processing');
158
+ for (let i = 0; i < maxPolls && !TERMINAL_STATUSES.has(status); i += 1) {
159
+ await sleep(pollIntervalMs);
160
+ try {
161
+ const res = await fetchImpl(`${opts.origin}/api/datasets/${encodeURIComponent(datasetId)}/publish-status`, {
162
+ headers: { authorization: `Bearer ${opts.apiKey}` },
163
+ });
164
+ if (res.ok) {
165
+ const json = await res.json().catch(() => ({}));
166
+ status = String(json.dataset?.publishStatus || status);
167
+ }
168
+ }
169
+ catch {
170
+ break; // network blip — stop polling, report what we have
171
+ }
172
+ }
173
+ return { ok: true, datasetId, status, listingUrl };
174
+ }
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "sella-cli",
3
+ "version": "0.5.0",
4
+ "description": "Sella onboarding CLI — install Sella into your agent clients, pair, verify, fund, publish. (`npx sella-cli init`)",
5
+ "license": "MIT",
6
+ "homepage": "https://sella.network",
7
+ "repository": { "type": "git", "url": "https://github.com/010100100100011101010100/ogsella", "directory": "cli" },
8
+ "type": "module",
9
+ "bin": { "sella": "./dist/index.js" },
10
+ "main": "./dist/index.js",
11
+ "files": ["dist", "README.md"],
12
+ "engines": { "node": ">=18" },
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.json",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5"
19
+ }
20
+ }