@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 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
- return {
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,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerExportCommand(program: Command): void;
@@ -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,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerImportCommand(program: Command): void;
@@ -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,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerLoginCommand(program: Command): void;
@@ -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">&#10003;</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
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerLogoutCommand(program: Command): void;
@@ -0,0 +1,10 @@
1
+ import { clearSession } from '../config/session.js';
2
+ export function registerLogoutCommand(program) {
3
+ program
4
+ .command('logout')
5
+ .description('Log out and clear session')
6
+ .action(() => {
7
+ clearSession();
8
+ console.log('Logged out.');
9
+ });
10
+ }
@@ -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('0.1.0')
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,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerStatusCommand(program: Command): void;
@@ -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
+ }
@@ -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
- * Resolve CLI configuration from flags > env > config file.
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;
@@ -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
- * Resolve CLI configuration from flags > env > config file.
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
- 'http://localhost:3000',
15
- apiKey: flags.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
+ }
@@ -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.0",
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.7.3",
25
+ "typescript": "^5.9.3",
22
26
  "vitest": "^3.1.0"
23
27
  },
24
28
  "scripts": {