@wafflebase/cli 0.1.0 → 0.2.1
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/dist/bin.js +12 -0
- package/dist/client/http-client.d.ts +4 -0
- package/dist/client/http-client.js +69 -2
- package/dist/commands/ctx.d.ts +5 -0
- package/dist/commands/ctx.js +56 -0
- package/dist/commands/export.d.ts +2 -0
- package/dist/commands/export.js +52 -0
- package/dist/commands/import.d.ts +2 -0
- package/dist/commands/import.js +88 -0
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +220 -0
- package/dist/commands/logout.d.ts +2 -0
- package/dist/commands/logout.js +10 -0
- package/dist/commands/root.js +4 -1
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +22 -0
- package/dist/config/config.d.ts +22 -1
- package/dist/config/config.js +98 -12
- package/dist/config/session.d.ts +49 -0
- package/dist/config/session.js +74 -0
- package/dist/schema/registry.js +65 -0
- package/dist/util/csv-parse.d.ts +21 -0
- package/dist/util/csv-parse.js +99 -0
- package/package.json +6 -2
package/dist/bin.js
CHANGED
|
@@ -5,10 +5,22 @@ import { registerTabCommand } from './commands/tab.js';
|
|
|
5
5
|
import { registerCellCommand } from './commands/cell.js';
|
|
6
6
|
import { registerApiKeyCommand } from './commands/api-key.js';
|
|
7
7
|
import { registerSchemaCommand } from './commands/schema.js';
|
|
8
|
+
import { registerLoginCommand } from './commands/login.js';
|
|
9
|
+
import { registerLogoutCommand } from './commands/logout.js';
|
|
10
|
+
import { registerStatusCommand } from './commands/status.js';
|
|
11
|
+
import { registerCtxCommand } from './commands/ctx.js';
|
|
12
|
+
import { registerImportCommand } from './commands/import.js';
|
|
13
|
+
import { registerExportCommand } from './commands/export.js';
|
|
8
14
|
const program = createProgram();
|
|
15
|
+
registerLoginCommand(program);
|
|
16
|
+
registerLogoutCommand(program);
|
|
17
|
+
registerStatusCommand(program);
|
|
18
|
+
registerCtxCommand(program);
|
|
9
19
|
registerDocumentCommand(program);
|
|
10
20
|
registerTabCommand(program);
|
|
11
21
|
registerCellCommand(program);
|
|
12
22
|
registerApiKeyCommand(program);
|
|
23
|
+
registerImportCommand(program);
|
|
24
|
+
registerExportCommand(program);
|
|
13
25
|
registerSchemaCommand(program);
|
|
14
26
|
program.parse();
|
|
@@ -16,6 +16,10 @@ export declare class HttpClient {
|
|
|
16
16
|
constructor(config: CliConfig);
|
|
17
17
|
private get headers();
|
|
18
18
|
private get base();
|
|
19
|
+
/**
|
|
20
|
+
* Attempt to refresh the JWT session. Returns true on success.
|
|
21
|
+
*/
|
|
22
|
+
private refreshSession;
|
|
19
23
|
request<T>(method: string, path: string, body?: unknown): Promise<ApiResponse<T>>;
|
|
20
24
|
listDocuments(): Promise<ApiResponse<unknown[]>>;
|
|
21
25
|
createDocument(title: string): Promise<ApiResponse<unknown>>;
|
|
@@ -1,18 +1,58 @@
|
|
|
1
|
+
import { loadSession, saveSession, decodeJwtExpiry, } from '../config/session.js';
|
|
1
2
|
export class HttpClient {
|
|
2
3
|
config;
|
|
3
4
|
constructor(config) {
|
|
4
5
|
this.config = config;
|
|
5
6
|
}
|
|
6
7
|
get headers() {
|
|
7
|
-
|
|
8
|
+
const h = {
|
|
8
9
|
'Content-Type': 'application/json',
|
|
9
|
-
Authorization: `Bearer ${this.config.apiKey}`,
|
|
10
10
|
};
|
|
11
|
+
if (this.config.authMode === 'api-key' && this.config.apiKey) {
|
|
12
|
+
h['Authorization'] = `Bearer ${this.config.apiKey}`;
|
|
13
|
+
}
|
|
14
|
+
else if (this.config.authMode === 'jwt' && this.config.accessToken) {
|
|
15
|
+
h['Authorization'] = `Bearer ${this.config.accessToken}`;
|
|
16
|
+
}
|
|
17
|
+
return h;
|
|
11
18
|
}
|
|
12
19
|
get base() {
|
|
13
20
|
const server = this.config.server.replace(/\/$/, '');
|
|
14
21
|
return `${server}/api/v1/workspaces/${this.config.workspace}`;
|
|
15
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Attempt to refresh the JWT session. Returns true on success.
|
|
25
|
+
*/
|
|
26
|
+
async refreshSession() {
|
|
27
|
+
if (!this.config.refreshToken)
|
|
28
|
+
return false;
|
|
29
|
+
const server = this.config.server.replace(/\/$/, '');
|
|
30
|
+
const res = await fetch(`${server}/auth/refresh`, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'Content-Type': 'application/json' },
|
|
33
|
+
body: JSON.stringify({ refreshToken: this.config.refreshToken }),
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok)
|
|
36
|
+
return false;
|
|
37
|
+
const data = (await res.json().catch(() => null));
|
|
38
|
+
if (!data?.accessToken || !data?.refreshToken)
|
|
39
|
+
return false;
|
|
40
|
+
// Update in-memory config
|
|
41
|
+
this.config.accessToken = data.accessToken;
|
|
42
|
+
this.config.refreshToken = data.refreshToken;
|
|
43
|
+
// Persist refreshed tokens to session file (if it exists).
|
|
44
|
+
// If the session file is missing the in-memory config is already
|
|
45
|
+
// updated, so the current process will keep working; the user can
|
|
46
|
+
// run `wafflebase login` to recreate the session file.
|
|
47
|
+
const session = loadSession();
|
|
48
|
+
if (session) {
|
|
49
|
+
session.accessToken = data.accessToken;
|
|
50
|
+
session.refreshToken = data.refreshToken;
|
|
51
|
+
session.expiresAt = decodeJwtExpiry(data.accessToken);
|
|
52
|
+
saveSession(session);
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
16
56
|
async request(method, path, body) {
|
|
17
57
|
const url = `${this.base}${path}`;
|
|
18
58
|
const res = await fetch(url, {
|
|
@@ -20,6 +60,33 @@ export class HttpClient {
|
|
|
20
60
|
headers: this.headers,
|
|
21
61
|
body: body ? JSON.stringify(body) : undefined,
|
|
22
62
|
});
|
|
63
|
+
// Auto-refresh on 401 for JWT auth (one attempt only)
|
|
64
|
+
if (res.status === 401 &&
|
|
65
|
+
this.config.authMode === 'jwt' &&
|
|
66
|
+
this.config.refreshToken) {
|
|
67
|
+
const refreshed = await this.refreshSession();
|
|
68
|
+
if (refreshed) {
|
|
69
|
+
// Retry the original request with new token
|
|
70
|
+
const retryRes = await fetch(url, {
|
|
71
|
+
method,
|
|
72
|
+
headers: this.headers,
|
|
73
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
74
|
+
});
|
|
75
|
+
const retryData = (await retryRes.json().catch(() => null));
|
|
76
|
+
return { ok: retryRes.ok, status: retryRes.status, data: retryData };
|
|
77
|
+
}
|
|
78
|
+
// Refresh failed — return a clear error
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
status: 401,
|
|
82
|
+
data: {
|
|
83
|
+
error: {
|
|
84
|
+
code: 'SESSION_EXPIRED',
|
|
85
|
+
message: 'Session expired. Run `wafflebase login`.',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
23
90
|
const data = (await res.json().catch(() => null));
|
|
24
91
|
return { ok: res.ok, status: res.status, data };
|
|
25
92
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import type { WorkspaceInfo } from '../config/session.js';
|
|
3
|
+
export declare function formatWorkspaceList(workspaces: WorkspaceInfo[], activeId: string): string;
|
|
4
|
+
export declare function findWorkspace(workspaces: WorkspaceInfo[], query: string): WorkspaceInfo | undefined;
|
|
5
|
+
export declare function registerCtxCommand(program: Command): void;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { loadSession, saveSession } from '../config/session.js';
|
|
2
|
+
export function formatWorkspaceList(workspaces, activeId) {
|
|
3
|
+
return workspaces
|
|
4
|
+
.map((ws) => {
|
|
5
|
+
const marker = ws.id === activeId ? '*' : ' ';
|
|
6
|
+
return `${marker} ${ws.id.slice(0, 8)} ${ws.name}`;
|
|
7
|
+
})
|
|
8
|
+
.join('\n');
|
|
9
|
+
}
|
|
10
|
+
export function findWorkspace(workspaces, query) {
|
|
11
|
+
// Exact ID match
|
|
12
|
+
const byId = workspaces.find((ws) => ws.id === query);
|
|
13
|
+
if (byId)
|
|
14
|
+
return byId;
|
|
15
|
+
// Exact name match (case-insensitive)
|
|
16
|
+
const byName = workspaces.find((ws) => ws.name.toLowerCase() === query.toLowerCase());
|
|
17
|
+
if (byName)
|
|
18
|
+
return byName;
|
|
19
|
+
// Prefix match on ID
|
|
20
|
+
const byPrefix = workspaces.filter((ws) => ws.id.startsWith(query));
|
|
21
|
+
if (byPrefix.length === 1)
|
|
22
|
+
return byPrefix[0];
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
export function registerCtxCommand(program) {
|
|
26
|
+
const ctx = program.command('ctx').description('Workspace context switching');
|
|
27
|
+
ctx
|
|
28
|
+
.command('list')
|
|
29
|
+
.description('List workspaces')
|
|
30
|
+
.action(() => {
|
|
31
|
+
const session = loadSession();
|
|
32
|
+
if (!session) {
|
|
33
|
+
console.log('Not logged in. Run `wafflebase login`.');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
console.log(formatWorkspaceList(session.workspaces, session.activeWorkspace));
|
|
37
|
+
});
|
|
38
|
+
ctx
|
|
39
|
+
.command('switch <name-or-id>')
|
|
40
|
+
.description('Switch active workspace')
|
|
41
|
+
.action((query) => {
|
|
42
|
+
const session = loadSession();
|
|
43
|
+
if (!session) {
|
|
44
|
+
console.error('Not logged in. Run `wafflebase login`.');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const ws = findWorkspace(session.workspaces, query);
|
|
48
|
+
if (!ws) {
|
|
49
|
+
console.error(`Workspace not found: ${query}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
session.activeWorkspace = ws.id;
|
|
53
|
+
saveSession(session);
|
|
54
|
+
console.log(`Switched to ${ws.name}.`);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { getGlobalOpts, getClient } from './root.js';
|
|
4
|
+
import { outputError } from '../output/formatter.js';
|
|
5
|
+
import { formatCsv } from '../output/csv.js';
|
|
6
|
+
import { formatJson } from '../output/json.js';
|
|
7
|
+
const VALID_FORMATS = ['csv', 'json'];
|
|
8
|
+
function detectFormat(file, formatFlag) {
|
|
9
|
+
if (formatFlag) {
|
|
10
|
+
if (!VALID_FORMATS.includes(formatFlag)) {
|
|
11
|
+
throw new Error(`Unsupported format "${formatFlag}". Use csv or json.`);
|
|
12
|
+
}
|
|
13
|
+
return formatFlag;
|
|
14
|
+
}
|
|
15
|
+
const ext = extname(file).toLowerCase();
|
|
16
|
+
if (ext === '.json')
|
|
17
|
+
return 'json';
|
|
18
|
+
return 'csv';
|
|
19
|
+
}
|
|
20
|
+
export function registerExportCommand(program) {
|
|
21
|
+
program
|
|
22
|
+
.command('export <doc-id> <file>')
|
|
23
|
+
.description('Export tab data to CSV/JSON')
|
|
24
|
+
.option('--tab <tab-id>', 'Source tab', 'tab-1')
|
|
25
|
+
.option('--range <range>', 'Cell range to export (e.g. A1:D100)')
|
|
26
|
+
.option('--file-format <fmt>', 'File format (csv, json)')
|
|
27
|
+
.action(async function (docId, file) {
|
|
28
|
+
const opts = getGlobalOpts(this);
|
|
29
|
+
const localOpts = this.opts();
|
|
30
|
+
try {
|
|
31
|
+
const res = await getClient(opts).getCells(docId, localOpts.tab, localOpts.range);
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const msg = res.data?.error?.message;
|
|
34
|
+
throw new Error(msg ?? `HTTP ${res.status}`);
|
|
35
|
+
}
|
|
36
|
+
const fmt = detectFormat(file, localOpts.fileFormat);
|
|
37
|
+
const formatted = fmt === 'csv' ? formatCsv(res.data) : formatJson(res.data);
|
|
38
|
+
if (file === '-') {
|
|
39
|
+
process.stdout.write(formatted + '\n');
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
writeFileSync(file, formatted + '\n', 'utf-8');
|
|
43
|
+
if (!opts.quiet) {
|
|
44
|
+
console.log(`Exported to ${file}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
outputError(e, opts.quiet);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { getGlobalOpts, getClient, getConfig } from './root.js';
|
|
4
|
+
import { output, outputError } from '../output/formatter.js';
|
|
5
|
+
import { printDryRun } from '../client/dry-run.js';
|
|
6
|
+
import { parseCsv, parseStartRef, buildCellMap } from '../util/csv-parse.js';
|
|
7
|
+
const VALID_FORMATS = ['csv', 'json'];
|
|
8
|
+
function detectFormat(file, formatFlag) {
|
|
9
|
+
if (formatFlag) {
|
|
10
|
+
if (!VALID_FORMATS.includes(formatFlag)) {
|
|
11
|
+
throw new Error(`Unsupported format "${formatFlag}". Use csv or json.`);
|
|
12
|
+
}
|
|
13
|
+
return formatFlag;
|
|
14
|
+
}
|
|
15
|
+
const ext = extname(file).toLowerCase();
|
|
16
|
+
if (ext === '.json')
|
|
17
|
+
return 'json';
|
|
18
|
+
return 'csv';
|
|
19
|
+
}
|
|
20
|
+
function readInput(file) {
|
|
21
|
+
if (file === '-') {
|
|
22
|
+
return readFileSync(0, 'utf-8');
|
|
23
|
+
}
|
|
24
|
+
return readFileSync(file, 'utf-8');
|
|
25
|
+
}
|
|
26
|
+
export function registerImportCommand(program) {
|
|
27
|
+
program
|
|
28
|
+
.command('import <doc-id> <file>')
|
|
29
|
+
.description('Import CSV/JSON into a tab')
|
|
30
|
+
.option('--tab <tab-id>', 'Target tab', 'tab-1')
|
|
31
|
+
.option('--file-format <fmt>', 'File format (csv, json)')
|
|
32
|
+
.option('--start <ref>', 'Top-left cell to start import', 'A1')
|
|
33
|
+
.action(async function (docId, file) {
|
|
34
|
+
const opts = getGlobalOpts(this);
|
|
35
|
+
const localOpts = this.opts();
|
|
36
|
+
try {
|
|
37
|
+
const fmt = detectFormat(file, localOpts.fileFormat);
|
|
38
|
+
const raw = readInput(file);
|
|
39
|
+
let rows;
|
|
40
|
+
if (fmt === 'json') {
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
// Expect either array of arrays or array of objects
|
|
43
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
44
|
+
if (Array.isArray(parsed[0])) {
|
|
45
|
+
rows = parsed;
|
|
46
|
+
}
|
|
47
|
+
else if (typeof parsed[0] === 'object') {
|
|
48
|
+
// Array of objects → header row + value rows
|
|
49
|
+
const objs = parsed;
|
|
50
|
+
const keys = Object.keys(objs[0]);
|
|
51
|
+
rows = [keys, ...objs.map((o) => keys.map((k) => String(o[k] ?? '')))];
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
throw new Error('JSON must be an array of arrays or array of objects');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
throw new Error('JSON must be a non-empty array');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
rows = parseCsv(raw);
|
|
63
|
+
}
|
|
64
|
+
if (rows.length === 0) {
|
|
65
|
+
throw new Error('No data to import');
|
|
66
|
+
}
|
|
67
|
+
const { row: startRow, col: startCol } = parseStartRef(localOpts.start);
|
|
68
|
+
const cells = buildCellMap(rows, startRow, startCol);
|
|
69
|
+
const cellCount = Object.keys(cells).length;
|
|
70
|
+
if (opts.dryRun) {
|
|
71
|
+
printDryRun(getConfig(opts), 'PATCH', `/documents/${docId}/tabs/${localOpts.tab}/cells`, {
|
|
72
|
+
cells,
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const res = await getClient(opts).batchCells(docId, localOpts.tab, cells);
|
|
77
|
+
if (!res.ok)
|
|
78
|
+
throw new Error(`HTTP ${res.status}`);
|
|
79
|
+
const result = typeof res.data === 'object' && res.data !== null
|
|
80
|
+
? { imported: cellCount, ...res.data }
|
|
81
|
+
: { imported: cellCount };
|
|
82
|
+
output(result, opts.format, opts.quiet);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
outputError(e, opts.quiet);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { loadSession, saveSession, decodeJwtExpiry, } from '../config/session.js';
|
|
4
|
+
import { DEFAULT_SERVER } from '../config/config.js';
|
|
5
|
+
export function registerLoginCommand(program) {
|
|
6
|
+
program
|
|
7
|
+
.command('login')
|
|
8
|
+
.description('Log in via GitHub OAuth in the browser')
|
|
9
|
+
.action(async function () {
|
|
10
|
+
const parentOpts = this.optsWithGlobals();
|
|
11
|
+
const server = (parentOpts.server ?? DEFAULT_SERVER).replace(/\/$/, '');
|
|
12
|
+
// 1. Check existing session
|
|
13
|
+
const existing = loadSession();
|
|
14
|
+
if (existing) {
|
|
15
|
+
const answer = await ask(`Logged in as ${existing.user.username}. Continue? [Y/n] `);
|
|
16
|
+
if (answer.toLowerCase() === 'n') {
|
|
17
|
+
console.log('Cancelled.');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// 2. Start local HTTP server
|
|
22
|
+
const { port, waitForCallback, close } = await startCallbackServer();
|
|
23
|
+
// 3. Build OAuth URL and open browser
|
|
24
|
+
const oauthUrl = `${server}/auth/github?mode=cli&port=${port}`;
|
|
25
|
+
console.error(`Opening browser: ${oauthUrl}`);
|
|
26
|
+
console.error('If the browser does not open, visit the URL above.');
|
|
27
|
+
try {
|
|
28
|
+
const open = (await import('open')).default;
|
|
29
|
+
await open(oauthUrl);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Browser open failed — URL was already printed
|
|
33
|
+
}
|
|
34
|
+
// 4. Wait for callback
|
|
35
|
+
let code;
|
|
36
|
+
try {
|
|
37
|
+
code = await waitForCallback();
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
close();
|
|
41
|
+
}
|
|
42
|
+
// 5. Exchange code for tokens
|
|
43
|
+
const exchangeRes = await fetch(`${server}/auth/cli/exchange`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ code }),
|
|
47
|
+
});
|
|
48
|
+
if (!exchangeRes.ok) {
|
|
49
|
+
console.error('Token exchange failed. Try again with `wafflebase login`.');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
const tokens = (await exchangeRes.json());
|
|
53
|
+
// 6. Get user info
|
|
54
|
+
const meRes = await fetch(`${server}/auth/me`, {
|
|
55
|
+
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
56
|
+
});
|
|
57
|
+
if (!meRes.ok) {
|
|
58
|
+
console.error('Failed to fetch user info.');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
const user = (await meRes.json());
|
|
62
|
+
// 7. Get workspace list
|
|
63
|
+
const wsRes = await fetch(`${server}/workspaces`, {
|
|
64
|
+
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
|
65
|
+
});
|
|
66
|
+
if (!wsRes.ok) {
|
|
67
|
+
console.error(`Failed to fetch workspaces (HTTP ${wsRes.status}). Try again with \`wafflebase login\`.`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const workspaces = (await wsRes.json());
|
|
71
|
+
// 8. Select workspace
|
|
72
|
+
let activeWorkspace = '';
|
|
73
|
+
if (workspaces.length === 0) {
|
|
74
|
+
console.log('No workspaces found.');
|
|
75
|
+
}
|
|
76
|
+
else if (workspaces.length === 1) {
|
|
77
|
+
activeWorkspace = workspaces[0].id;
|
|
78
|
+
console.log(`Workspace: ${workspaces[0].name}`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
console.log('Select a workspace:');
|
|
82
|
+
workspaces.forEach((ws, i) => {
|
|
83
|
+
console.log(` ${i + 1}. ${ws.name} (${ws.id.slice(0, 8)})`);
|
|
84
|
+
});
|
|
85
|
+
const choice = await ask('Enter number: ');
|
|
86
|
+
const idx = parseInt(choice, 10) - 1;
|
|
87
|
+
if (idx >= 0 && idx < workspaces.length) {
|
|
88
|
+
activeWorkspace = workspaces[idx].id;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
activeWorkspace = workspaces[0].id;
|
|
92
|
+
console.log(`Invalid choice, using ${workspaces[0].name}.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// 9. Save session
|
|
96
|
+
const session = {
|
|
97
|
+
server,
|
|
98
|
+
user,
|
|
99
|
+
accessToken: tokens.accessToken,
|
|
100
|
+
refreshToken: tokens.refreshToken,
|
|
101
|
+
expiresAt: decodeJwtExpiry(tokens.accessToken),
|
|
102
|
+
activeWorkspace,
|
|
103
|
+
workspaces,
|
|
104
|
+
};
|
|
105
|
+
saveSession(session);
|
|
106
|
+
console.log(`Logged in as ${user.username}.`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function ask(prompt) {
|
|
110
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
rl.question(prompt, (answer) => {
|
|
113
|
+
rl.close();
|
|
114
|
+
resolve(answer.trim());
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function startCallbackServer() {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
let settled = false;
|
|
121
|
+
let callbackResolve;
|
|
122
|
+
let callbackReject;
|
|
123
|
+
const callbackPromise = new Promise((res, rej) => {
|
|
124
|
+
callbackResolve = res;
|
|
125
|
+
callbackReject = rej;
|
|
126
|
+
});
|
|
127
|
+
const srv = createServer((req, res) => {
|
|
128
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1`);
|
|
129
|
+
if (url.pathname !== '/callback') {
|
|
130
|
+
res.writeHead(404);
|
|
131
|
+
res.end('Not found');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const code = url.searchParams.get('code');
|
|
135
|
+
if (!code) {
|
|
136
|
+
res.writeHead(400);
|
|
137
|
+
res.end('Missing code');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
141
|
+
res.end(`<!DOCTYPE html>
|
|
142
|
+
<html lang="en">
|
|
143
|
+
<head>
|
|
144
|
+
<meta charset="utf-8">
|
|
145
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
146
|
+
<title>Wafflebase CLI</title>
|
|
147
|
+
<style>
|
|
148
|
+
body {
|
|
149
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
150
|
+
display: flex;
|
|
151
|
+
justify-content: center;
|
|
152
|
+
align-items: center;
|
|
153
|
+
min-height: 100vh;
|
|
154
|
+
margin: 0;
|
|
155
|
+
background: #fafafa;
|
|
156
|
+
color: #1a1a1a;
|
|
157
|
+
}
|
|
158
|
+
.card {
|
|
159
|
+
text-align: center;
|
|
160
|
+
padding: 3rem 2.5rem;
|
|
161
|
+
background: #fff;
|
|
162
|
+
border-radius: 12px;
|
|
163
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.04);
|
|
164
|
+
}
|
|
165
|
+
.icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
|
166
|
+
h2 { margin: 0 0 0.5rem; font-size: 1.25rem; font-weight: 600; }
|
|
167
|
+
p { margin: 0; color: #666; font-size: 0.95rem; }
|
|
168
|
+
</style>
|
|
169
|
+
</head>
|
|
170
|
+
<body>
|
|
171
|
+
<div class="card">
|
|
172
|
+
<div class="icon">✓</div>
|
|
173
|
+
<h2>Login successful!</h2>
|
|
174
|
+
<p>You can close this tab and return to the terminal.</p>
|
|
175
|
+
</div>
|
|
176
|
+
</body>
|
|
177
|
+
</html>`);
|
|
178
|
+
if (!settled) {
|
|
179
|
+
settled = true;
|
|
180
|
+
callbackResolve(code);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
const timeout = setTimeout(() => {
|
|
184
|
+
if (!settled) {
|
|
185
|
+
settled = true;
|
|
186
|
+
callbackReject(new Error('Login timed out. Try again with `wafflebase login`.'));
|
|
187
|
+
}
|
|
188
|
+
srv.close();
|
|
189
|
+
}, 30_000);
|
|
190
|
+
// Try to listen on a random port (up to 3 attempts)
|
|
191
|
+
let attempts = 0;
|
|
192
|
+
const tryListen = () => {
|
|
193
|
+
attempts++;
|
|
194
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
195
|
+
const addr = srv.address();
|
|
196
|
+
if (!addr || typeof addr === 'string') {
|
|
197
|
+
reject(new Error('Failed to start callback server'));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
resolve({
|
|
201
|
+
port: addr.port,
|
|
202
|
+
waitForCallback: () => callbackPromise,
|
|
203
|
+
close: () => {
|
|
204
|
+
clearTimeout(timeout);
|
|
205
|
+
srv.close();
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
srv.on('error', (err) => {
|
|
211
|
+
if (err.code === 'EADDRINUSE' && attempts < 3) {
|
|
212
|
+
tryListen();
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
reject(err);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
tryListen();
|
|
219
|
+
});
|
|
220
|
+
}
|
package/dist/commands/root.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
1
2
|
import { Command } from 'commander';
|
|
2
3
|
import { resolveConfig } from '../config/config.js';
|
|
3
4
|
import { HttpClient } from '../client/http-client.js';
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const { version } = require('../../package.json');
|
|
4
7
|
export function getGlobalOpts(cmd) {
|
|
5
8
|
const root = cmd.optsWithGlobals();
|
|
6
9
|
return root;
|
|
@@ -21,7 +24,7 @@ export function createProgram() {
|
|
|
21
24
|
program
|
|
22
25
|
.name('wafflebase')
|
|
23
26
|
.description('CLI for Wafflebase spreadsheet API')
|
|
24
|
-
.version(
|
|
27
|
+
.version(version)
|
|
25
28
|
.option('--server <url>', 'Server URL')
|
|
26
29
|
.option('--api-key <key>', 'API key')
|
|
27
30
|
.option('--workspace <id>', 'Workspace ID')
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { loadSession, isSessionExpired } from '../config/session.js';
|
|
2
|
+
export function registerStatusCommand(program) {
|
|
3
|
+
program
|
|
4
|
+
.command('status')
|
|
5
|
+
.description('Show current auth state')
|
|
6
|
+
.action(() => {
|
|
7
|
+
const session = loadSession();
|
|
8
|
+
if (!session) {
|
|
9
|
+
console.log('Not logged in. Run `wafflebase login`.');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
console.log(`Logged in as ${session.user.username} (${session.user.email})`);
|
|
13
|
+
console.log(`Server: ${session.server}`);
|
|
14
|
+
const ws = session.workspaces.find((w) => w.id === session.activeWorkspace);
|
|
15
|
+
const wsLabel = ws
|
|
16
|
+
? `${ws.name} (${session.activeWorkspace.slice(0, 8)}...)`
|
|
17
|
+
: session.activeWorkspace;
|
|
18
|
+
console.log(`Workspace: ${wsLabel}`);
|
|
19
|
+
const expired = isSessionExpired(session);
|
|
20
|
+
console.log(`Session: ${expired ? 'expired' : 'valid'} (expires ${session.expiresAt})`);
|
|
21
|
+
});
|
|
22
|
+
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
|
+
export declare const DEFAULT_SERVER = "https://api.wafflebase.io";
|
|
1
2
|
export interface CliConfig {
|
|
2
3
|
server: string;
|
|
3
4
|
apiKey: string;
|
|
4
5
|
workspace: string;
|
|
6
|
+
authMode: 'api-key' | 'jwt' | 'none';
|
|
7
|
+
accessToken?: string;
|
|
8
|
+
refreshToken?: string;
|
|
5
9
|
}
|
|
6
10
|
/**
|
|
7
|
-
*
|
|
11
|
+
* Migrate config from old path (~/.config/wafflebase/config.yaml) to new path
|
|
12
|
+
* (~/.wafflebase/config.yaml) if needed.
|
|
13
|
+
*/
|
|
14
|
+
export declare function migrateConfigIfNeeded(newPath: string, oldPath?: string): void;
|
|
15
|
+
/**
|
|
16
|
+
* Return the resolved config file path.
|
|
17
|
+
* WAFFLEBASE_CONFIG env var overrides everything; otherwise defaults to
|
|
18
|
+
* ~/.wafflebase/config.yaml (with migration from old ~/.config/wafflebase path).
|
|
19
|
+
*/
|
|
20
|
+
export declare function getConfigPath(): string;
|
|
21
|
+
/**
|
|
22
|
+
* Resolve CLI configuration from flags > env > session > config file.
|
|
23
|
+
*
|
|
24
|
+
* Auth resolution order:
|
|
25
|
+
* 1. Flag/env `--api-key` or `WAFFLEBASE_API_KEY` → API key auth
|
|
26
|
+
* 2. Session `~/.wafflebase/session.json` exists and token valid → JWT auth
|
|
27
|
+
* 3. Config profile `api-key` → API key auth
|
|
28
|
+
* 4. None → empty (commands will fail)
|
|
8
29
|
*/
|
|
9
30
|
export declare function resolveConfig(flags: {
|
|
10
31
|
server?: string;
|
package/dist/config/config.js
CHANGED
|
@@ -1,31 +1,117 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
import { loadSession, isSessionExpired } from './session.js';
|
|
6
|
+
export const DEFAULT_SERVER = 'https://api.wafflebase.io';
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
8
|
+
* Migrate config from old path (~/.config/wafflebase/config.yaml) to new path
|
|
9
|
+
* (~/.wafflebase/config.yaml) if needed.
|
|
10
|
+
*/
|
|
11
|
+
export function migrateConfigIfNeeded(newPath, oldPath = join(homedir(), '.config', 'wafflebase', 'config.yaml')) {
|
|
12
|
+
if (existsSync(newPath))
|
|
13
|
+
return;
|
|
14
|
+
if (!existsSync(oldPath))
|
|
15
|
+
return;
|
|
16
|
+
try {
|
|
17
|
+
mkdirSync(dirname(newPath), { recursive: true });
|
|
18
|
+
copyFileSync(oldPath, newPath);
|
|
19
|
+
process.stderr.write(`[wafflebase] Config migrated from ${oldPath} to ${newPath}\n`);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Silent failure — migration is best-effort
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Return the resolved config file path.
|
|
27
|
+
* WAFFLEBASE_CONFIG env var overrides everything; otherwise defaults to
|
|
28
|
+
* ~/.wafflebase/config.yaml (with migration from old ~/.config/wafflebase path).
|
|
29
|
+
*/
|
|
30
|
+
export function getConfigPath() {
|
|
31
|
+
if (process.env.WAFFLEBASE_CONFIG) {
|
|
32
|
+
return process.env.WAFFLEBASE_CONFIG;
|
|
33
|
+
}
|
|
34
|
+
const newPath = join(homedir(), '.wafflebase', 'config.yaml');
|
|
35
|
+
migrateConfigIfNeeded(newPath);
|
|
36
|
+
return newPath;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolve CLI configuration from flags > env > session > config file.
|
|
40
|
+
*
|
|
41
|
+
* Auth resolution order:
|
|
42
|
+
* 1. Flag/env `--api-key` or `WAFFLEBASE_API_KEY` → API key auth
|
|
43
|
+
* 2. Session `~/.wafflebase/session.json` exists and token valid → JWT auth
|
|
44
|
+
* 3. Config profile `api-key` → API key auth
|
|
45
|
+
* 4. None → empty (commands will fail)
|
|
7
46
|
*/
|
|
8
47
|
export function resolveConfig(flags) {
|
|
9
48
|
const profile = loadProfile(flags.profile ?? 'default');
|
|
49
|
+
// Step 1: Check flags/env for API key
|
|
50
|
+
const flagOrEnvApiKey = flags.apiKey ?? process.env.WAFFLEBASE_API_KEY ?? undefined;
|
|
51
|
+
if (flagOrEnvApiKey) {
|
|
52
|
+
return {
|
|
53
|
+
server: flags.server ??
|
|
54
|
+
process.env.WAFFLEBASE_SERVER ??
|
|
55
|
+
profile.server ??
|
|
56
|
+
DEFAULT_SERVER,
|
|
57
|
+
apiKey: flagOrEnvApiKey,
|
|
58
|
+
workspace: flags.workspace ??
|
|
59
|
+
process.env.WAFFLEBASE_WORKSPACE ??
|
|
60
|
+
profile.workspace ??
|
|
61
|
+
'',
|
|
62
|
+
authMode: 'api-key',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// Step 2: Try session
|
|
66
|
+
const session = loadSession();
|
|
67
|
+
if (session && !isSessionExpired(session)) {
|
|
68
|
+
return {
|
|
69
|
+
server: flags.server ??
|
|
70
|
+
process.env.WAFFLEBASE_SERVER ??
|
|
71
|
+
session.server ??
|
|
72
|
+
profile.server ??
|
|
73
|
+
DEFAULT_SERVER,
|
|
74
|
+
apiKey: '',
|
|
75
|
+
workspace: flags.workspace ??
|
|
76
|
+
process.env.WAFFLEBASE_WORKSPACE ??
|
|
77
|
+
session.activeWorkspace ??
|
|
78
|
+
profile.workspace ??
|
|
79
|
+
'',
|
|
80
|
+
authMode: 'jwt',
|
|
81
|
+
accessToken: session.accessToken,
|
|
82
|
+
refreshToken: session.refreshToken,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// Step 3: Fallback to config profile API key
|
|
86
|
+
const profileApiKey = profile['api-key'];
|
|
87
|
+
if (profileApiKey) {
|
|
88
|
+
return {
|
|
89
|
+
server: flags.server ??
|
|
90
|
+
process.env.WAFFLEBASE_SERVER ??
|
|
91
|
+
profile.server ??
|
|
92
|
+
DEFAULT_SERVER,
|
|
93
|
+
apiKey: profileApiKey,
|
|
94
|
+
workspace: flags.workspace ??
|
|
95
|
+
process.env.WAFFLEBASE_WORKSPACE ??
|
|
96
|
+
profile.workspace ??
|
|
97
|
+
'',
|
|
98
|
+
authMode: 'api-key',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// Step 4: Nothing available
|
|
10
102
|
return {
|
|
11
103
|
server: flags.server ??
|
|
12
104
|
process.env.WAFFLEBASE_SERVER ??
|
|
13
105
|
profile.server ??
|
|
14
|
-
|
|
15
|
-
apiKey:
|
|
16
|
-
process.env.WAFFLEBASE_API_KEY ??
|
|
17
|
-
profile['api-key'] ??
|
|
18
|
-
'',
|
|
106
|
+
DEFAULT_SERVER,
|
|
107
|
+
apiKey: '',
|
|
19
108
|
workspace: flags.workspace ??
|
|
20
109
|
process.env.WAFFLEBASE_WORKSPACE ??
|
|
21
110
|
profile.workspace ??
|
|
22
111
|
'',
|
|
112
|
+
authMode: 'none',
|
|
23
113
|
};
|
|
24
114
|
}
|
|
25
|
-
function getConfigPath() {
|
|
26
|
-
return (process.env.WAFFLEBASE_CONFIG ??
|
|
27
|
-
join(homedir(), '.config', 'wafflebase', 'config.yaml'));
|
|
28
|
-
}
|
|
29
115
|
function loadProfile(name) {
|
|
30
116
|
const configPath = getConfigPath();
|
|
31
117
|
if (!existsSync(configPath))
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface SessionUser {
|
|
2
|
+
id: number;
|
|
3
|
+
username: string;
|
|
4
|
+
email: string;
|
|
5
|
+
photo: string | null;
|
|
6
|
+
}
|
|
7
|
+
export interface WorkspaceInfo {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
export interface Session {
|
|
12
|
+
server: string;
|
|
13
|
+
user: SessionUser;
|
|
14
|
+
accessToken: string;
|
|
15
|
+
refreshToken: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
activeWorkspace: string;
|
|
18
|
+
workspaces: WorkspaceInfo[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Returns the default session file path: ~/.wafflebase/session.json
|
|
22
|
+
*/
|
|
23
|
+
export declare function getSessionPath(): string;
|
|
24
|
+
/**
|
|
25
|
+
* Loads the session from disk. Returns null if the file does not exist or
|
|
26
|
+
* contains invalid JSON.
|
|
27
|
+
*/
|
|
28
|
+
export declare function loadSession(path?: string): Session | null;
|
|
29
|
+
/**
|
|
30
|
+
* Writes the session to disk with owner-only (0600) permissions.
|
|
31
|
+
* Creates the parent directory if it does not exist.
|
|
32
|
+
*/
|
|
33
|
+
export declare function saveSession(session: Session, path?: string): void;
|
|
34
|
+
/**
|
|
35
|
+
* Deletes the session file if it exists. No-op otherwise.
|
|
36
|
+
*/
|
|
37
|
+
export declare function clearSession(path?: string): void;
|
|
38
|
+
/**
|
|
39
|
+
* Returns true when the session's expiresAt timestamp is in the past.
|
|
40
|
+
*/
|
|
41
|
+
export declare function isSessionExpired(session: Session): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Decodes the `exp` claim from a JWT's payload segment and returns an ISO
|
|
44
|
+
* 8601 string.
|
|
45
|
+
*
|
|
46
|
+
* JWT format: <header>.<payload>.<signature>
|
|
47
|
+
* The payload is base64url-encoded JSON.
|
|
48
|
+
*/
|
|
49
|
+
export declare function decodeJwtExpiry(token: string): string;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, chmodSync, } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
/**
|
|
5
|
+
* Returns the default session file path: ~/.wafflebase/session.json
|
|
6
|
+
*/
|
|
7
|
+
export function getSessionPath() {
|
|
8
|
+
if (process.env.WAFFLEBASE_SESSION) {
|
|
9
|
+
return process.env.WAFFLEBASE_SESSION;
|
|
10
|
+
}
|
|
11
|
+
return join(homedir(), '.wafflebase', 'session.json');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Loads the session from disk. Returns null if the file does not exist or
|
|
15
|
+
* contains invalid JSON.
|
|
16
|
+
*/
|
|
17
|
+
export function loadSession(path = getSessionPath()) {
|
|
18
|
+
if (!existsSync(path))
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
const raw = readFileSync(path, 'utf-8');
|
|
22
|
+
return JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Writes the session to disk with owner-only (0600) permissions.
|
|
30
|
+
* Creates the parent directory if it does not exist.
|
|
31
|
+
*/
|
|
32
|
+
export function saveSession(session, path = getSessionPath()) {
|
|
33
|
+
const dir = dirname(path);
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
writeFileSync(path, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
36
|
+
// Explicitly chmod in case the file already existed with different permissions
|
|
37
|
+
chmodSync(path, 0o600);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Deletes the session file if it exists. No-op otherwise.
|
|
41
|
+
*/
|
|
42
|
+
export function clearSession(path = getSessionPath()) {
|
|
43
|
+
if (existsSync(path)) {
|
|
44
|
+
unlinkSync(path);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Returns true when the session's expiresAt timestamp is in the past.
|
|
49
|
+
*/
|
|
50
|
+
export function isSessionExpired(session) {
|
|
51
|
+
return new Date(session.expiresAt) <= new Date();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Decodes the `exp` claim from a JWT's payload segment and returns an ISO
|
|
55
|
+
* 8601 string.
|
|
56
|
+
*
|
|
57
|
+
* JWT format: <header>.<payload>.<signature>
|
|
58
|
+
* The payload is base64url-encoded JSON.
|
|
59
|
+
*/
|
|
60
|
+
export function decodeJwtExpiry(token) {
|
|
61
|
+
const parts = token.split('.');
|
|
62
|
+
if (parts.length < 2) {
|
|
63
|
+
throw new Error('Invalid JWT: expected at least 2 segments');
|
|
64
|
+
}
|
|
65
|
+
// Add padding back if needed for base64 decoding
|
|
66
|
+
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
67
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
68
|
+
const decoded = Buffer.from(padded, 'base64').toString('utf-8');
|
|
69
|
+
const payload = JSON.parse(decoded);
|
|
70
|
+
if (typeof payload.exp !== 'number') {
|
|
71
|
+
throw new Error('JWT payload does not contain an exp claim');
|
|
72
|
+
}
|
|
73
|
+
return new Date(payload.exp * 1000).toISOString();
|
|
74
|
+
}
|
package/dist/schema/registry.js
CHANGED
|
@@ -1,4 +1,43 @@
|
|
|
1
1
|
const registry = [
|
|
2
|
+
{
|
|
3
|
+
name: 'login',
|
|
4
|
+
description: 'Authenticate via GitHub OAuth in the browser',
|
|
5
|
+
safety: 'write',
|
|
6
|
+
parameters: {
|
|
7
|
+
'--server': { type: 'string', required: false, description: 'Server URL', default: 'https://api.wafflebase.io' },
|
|
8
|
+
},
|
|
9
|
+
response: { user: 'string', workspace: 'string' },
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: 'logout',
|
|
13
|
+
description: 'Clear session and log out',
|
|
14
|
+
safety: 'write',
|
|
15
|
+
parameters: {},
|
|
16
|
+
response: {},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'status',
|
|
20
|
+
description: 'Show current auth state',
|
|
21
|
+
safety: 'read-only',
|
|
22
|
+
parameters: {},
|
|
23
|
+
response: { user: 'string', server: 'string', workspace: 'string', session: 'string' },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'ctx.list',
|
|
27
|
+
description: 'List workspaces',
|
|
28
|
+
safety: 'read-only',
|
|
29
|
+
parameters: {},
|
|
30
|
+
response: { type: 'array', items: { id: 'string', name: 'string', active: 'boolean' } },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'ctx.switch',
|
|
34
|
+
description: 'Switch active workspace',
|
|
35
|
+
safety: 'write',
|
|
36
|
+
parameters: {
|
|
37
|
+
'name-or-id': { type: 'string', required: true, description: 'Workspace name or ID' },
|
|
38
|
+
},
|
|
39
|
+
response: { workspace: 'string' },
|
|
40
|
+
},
|
|
2
41
|
{
|
|
3
42
|
name: 'doc.list',
|
|
4
43
|
description: 'List documents in workspace',
|
|
@@ -100,6 +139,32 @@ const registry = [
|
|
|
100
139
|
},
|
|
101
140
|
response: { ref: 'string', deleted: 'boolean' },
|
|
102
141
|
},
|
|
142
|
+
{
|
|
143
|
+
name: 'import',
|
|
144
|
+
description: 'Import CSV/JSON into a tab',
|
|
145
|
+
safety: 'write',
|
|
146
|
+
parameters: {
|
|
147
|
+
'doc-id': { type: 'string', required: true, description: 'Document ID' },
|
|
148
|
+
file: { type: 'string', required: true, description: 'File path or - for stdin' },
|
|
149
|
+
'--tab': { type: 'string', required: false, description: 'Target tab', default: 'tab-1' },
|
|
150
|
+
'--file-format': { type: 'string', required: false, description: 'File format (csv, json)' },
|
|
151
|
+
'--start': { type: 'string', required: false, description: 'Top-left cell', default: 'A1' },
|
|
152
|
+
},
|
|
153
|
+
response: { imported: 'number' },
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: 'export',
|
|
157
|
+
description: 'Export tab data to CSV/JSON',
|
|
158
|
+
safety: 'read-only',
|
|
159
|
+
parameters: {
|
|
160
|
+
'doc-id': { type: 'string', required: true, description: 'Document ID' },
|
|
161
|
+
file: { type: 'string', required: true, description: 'File path or - for stdout' },
|
|
162
|
+
'--tab': { type: 'string', required: false, description: 'Source tab', default: 'tab-1' },
|
|
163
|
+
'--range': { type: 'string', required: false, description: 'Cell range (e.g. A1:D100)' },
|
|
164
|
+
'--file-format': { type: 'string', required: false, description: 'File format (csv, json)' },
|
|
165
|
+
},
|
|
166
|
+
response: { type: 'string', description: 'Formatted cell data' },
|
|
167
|
+
},
|
|
103
168
|
{
|
|
104
169
|
name: 'api-key.create',
|
|
105
170
|
description: 'Create a new API key',
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse CSV text into a 2D array of strings.
|
|
3
|
+
*/
|
|
4
|
+
export declare function parseCsv(text: string): string[][];
|
|
5
|
+
/**
|
|
6
|
+
* Convert a 1-based column number to a column label (1→A, 2→B, 27→AA).
|
|
7
|
+
*/
|
|
8
|
+
export declare function toColumnLabel(col: number): string;
|
|
9
|
+
/**
|
|
10
|
+
* Parse a cell reference like "A1" into 1-based row and column numbers.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseStartRef(ref: string): {
|
|
13
|
+
row: number;
|
|
14
|
+
col: number;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Build a cell map from a 2D array for batch API.
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildCellMap(data: string[][], startRow: number, startCol: number): Record<string, {
|
|
20
|
+
value: string;
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse CSV text into a 2D array of strings.
|
|
3
|
+
*/
|
|
4
|
+
export function parseCsv(text) {
|
|
5
|
+
const rows = [];
|
|
6
|
+
let current = '';
|
|
7
|
+
let inQuotes = false;
|
|
8
|
+
let row = [];
|
|
9
|
+
for (let i = 0; i < text.length; i++) {
|
|
10
|
+
const ch = text[i];
|
|
11
|
+
if (inQuotes) {
|
|
12
|
+
if (ch === '"') {
|
|
13
|
+
if (i + 1 < text.length && text[i + 1] === '"') {
|
|
14
|
+
current += '"';
|
|
15
|
+
i++;
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
inQuotes = false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
current += ch;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else if (ch === '"') {
|
|
26
|
+
inQuotes = true;
|
|
27
|
+
}
|
|
28
|
+
else if (ch === ',') {
|
|
29
|
+
row.push(current);
|
|
30
|
+
current = '';
|
|
31
|
+
}
|
|
32
|
+
else if (ch === '\n' || ch === '\r') {
|
|
33
|
+
if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') {
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
row.push(current);
|
|
37
|
+
current = '';
|
|
38
|
+
if (row.length > 0)
|
|
39
|
+
rows.push(row);
|
|
40
|
+
row = [];
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
current += ch;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// last field/row
|
|
47
|
+
row.push(current);
|
|
48
|
+
if (row.some((cell) => cell !== ''))
|
|
49
|
+
rows.push(row);
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Convert a 1-based column number to a column label (1→A, 2→B, 27→AA).
|
|
54
|
+
*/
|
|
55
|
+
export function toColumnLabel(col) {
|
|
56
|
+
let label = '';
|
|
57
|
+
while (col > 0) {
|
|
58
|
+
const rem = col % 26;
|
|
59
|
+
if (rem === 0) {
|
|
60
|
+
label = 'Z' + label;
|
|
61
|
+
col = Math.floor(col / 26) - 1;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
label = String.fromCharCode(rem + 64) + label;
|
|
65
|
+
col = Math.floor(col / 26);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return label;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Parse a cell reference like "A1" into 1-based row and column numbers.
|
|
72
|
+
*/
|
|
73
|
+
export function parseStartRef(ref) {
|
|
74
|
+
const match = ref.match(/^([A-Z]+)(\d+)$/i);
|
|
75
|
+
if (!match)
|
|
76
|
+
return { row: 1, col: 1 };
|
|
77
|
+
const letters = match[1].toUpperCase();
|
|
78
|
+
let col = 0;
|
|
79
|
+
for (const ch of letters) {
|
|
80
|
+
col = col * 26 + (ch.charCodeAt(0) - 64);
|
|
81
|
+
}
|
|
82
|
+
return { row: parseInt(match[2], 10), col };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Build a cell map from a 2D array for batch API.
|
|
86
|
+
*/
|
|
87
|
+
export function buildCellMap(data, startRow, startCol) {
|
|
88
|
+
const cells = {};
|
|
89
|
+
for (let r = 0; r < data.length; r++) {
|
|
90
|
+
for (let c = 0; c < data[r].length; c++) {
|
|
91
|
+
const value = data[r][c];
|
|
92
|
+
if (value === '')
|
|
93
|
+
continue;
|
|
94
|
+
const ref = toColumnLabel(startCol + c) + (startRow + r);
|
|
95
|
+
cells[ref] = { value };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return cells;
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wafflebase/cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "CLI for Wafflebase spreadsheet API",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"wafflebase": "./dist/bin.js"
|
|
9
9
|
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20"
|
|
12
|
+
},
|
|
10
13
|
"files": [
|
|
11
14
|
"dist"
|
|
12
15
|
],
|
|
13
16
|
"dependencies": {
|
|
14
17
|
"commander": "^13.0.0",
|
|
18
|
+
"open": "^11.0.0",
|
|
15
19
|
"yaml": "^2.0.0"
|
|
16
20
|
},
|
|
17
21
|
"devDependencies": {
|
|
18
22
|
"@types/node": "^25.4.0",
|
|
19
23
|
"eslint": "^9.18.0",
|
|
20
24
|
"tsx": "^4.19.0",
|
|
21
|
-
"typescript": "^5.
|
|
25
|
+
"typescript": "^5.9.3",
|
|
22
26
|
"vitest": "^3.1.0"
|
|
23
27
|
},
|
|
24
28
|
"scripts": {
|