noxctl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +326 -0
- package/dist/auth.d.ts +29 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +267 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1012 -0
- package/dist/cli.js.map +1 -0
- package/dist/credentials-store.d.ts +4 -0
- package/dist/credentials-store.d.ts.map +1 -0
- package/dist/credentials-store.js +223 -0
- package/dist/credentials-store.js.map +1 -0
- package/dist/formatter.d.ts +62 -0
- package/dist/formatter.d.ts.map +1 -0
- package/dist/formatter.js +200 -0
- package/dist/formatter.js.map +1 -0
- package/dist/fortnox-client.d.ts +27 -0
- package/dist/fortnox-client.d.ts.map +1 -0
- package/dist/fortnox-client.js +178 -0
- package/dist/fortnox-client.js.map +1 -0
- package/dist/identifiers.d.ts +4 -0
- package/dist/identifiers.d.ts.map +1 -0
- package/dist/identifiers.js +16 -0
- package/dist/identifiers.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/operations/accounts.d.ts +18 -0
- package/dist/operations/accounts.d.ts.map +1 -0
- package/dist/operations/accounts.js +28 -0
- package/dist/operations/accounts.js.map +1 -0
- package/dist/operations/articles.d.ts +20 -0
- package/dist/operations/articles.d.ts.map +1 -0
- package/dist/operations/articles.js +36 -0
- package/dist/operations/articles.js.map +1 -0
- package/dist/operations/company.d.ts +2 -0
- package/dist/operations/company.d.ts.map +1 -0
- package/dist/operations/company.js +6 -0
- package/dist/operations/company.js.map +1 -0
- package/dist/operations/customers.d.ts +20 -0
- package/dist/operations/customers.d.ts.map +1 -0
- package/dist/operations/customers.js +37 -0
- package/dist/operations/customers.js.map +1 -0
- package/dist/operations/financial-reports.d.ts +37 -0
- package/dist/operations/financial-reports.d.ts.map +1 -0
- package/dist/operations/financial-reports.js +193 -0
- package/dist/operations/financial-reports.js.map +1 -0
- package/dist/operations/invoices.d.ts +32 -0
- package/dist/operations/invoices.d.ts.map +1 -0
- package/dist/operations/invoices.js +74 -0
- package/dist/operations/invoices.js.map +1 -0
- package/dist/operations/supplier-invoices.d.ts +23 -0
- package/dist/operations/supplier-invoices.d.ts.map +1 -0
- package/dist/operations/supplier-invoices.js +36 -0
- package/dist/operations/supplier-invoices.js.map +1 -0
- package/dist/operations/suppliers.d.ts +20 -0
- package/dist/operations/suppliers.d.ts.map +1 -0
- package/dist/operations/suppliers.js +36 -0
- package/dist/operations/suppliers.js.map +1 -0
- package/dist/operations/tax.d.ts +26 -0
- package/dist/operations/tax.d.ts.map +1 -0
- package/dist/operations/tax.js +47 -0
- package/dist/operations/tax.js.map +1 -0
- package/dist/operations/vouchers.d.ts +22 -0
- package/dist/operations/vouchers.d.ts.map +1 -0
- package/dist/operations/vouchers.js +41 -0
- package/dist/operations/vouchers.js.map +1 -0
- package/dist/tool-output.d.ts +16 -0
- package/dist/tool-output.d.ts.map +1 -0
- package/dist/tool-output.js +48 -0
- package/dist/tool-output.js.map +1 -0
- package/dist/tools/articles.d.ts +3 -0
- package/dist/tools/articles.d.ts.map +1 -0
- package/dist/tools/articles.js +75 -0
- package/dist/tools/articles.js.map +1 -0
- package/dist/tools/bookkeeping.d.ts +3 -0
- package/dist/tools/bookkeeping.d.ts.map +1 -0
- package/dist/tools/bookkeeping.js +82 -0
- package/dist/tools/bookkeeping.js.map +1 -0
- package/dist/tools/company.d.ts +3 -0
- package/dist/tools/company.d.ts.map +1 -0
- package/dist/tools/company.js +13 -0
- package/dist/tools/company.js.map +1 -0
- package/dist/tools/customers.d.ts +3 -0
- package/dist/tools/customers.d.ts.map +1 -0
- package/dist/tools/customers.js +75 -0
- package/dist/tools/customers.js.map +1 -0
- package/dist/tools/financial-reports.d.ts +3 -0
- package/dist/tools/financial-reports.d.ts.map +1 -0
- package/dist/tools/financial-reports.js +35 -0
- package/dist/tools/financial-reports.js.map +1 -0
- package/dist/tools/invoices.d.ts +3 -0
- package/dist/tools/invoices.d.ts.map +1 -0
- package/dist/tools/invoices.js +160 -0
- package/dist/tools/invoices.js.map +1 -0
- package/dist/tools/status.d.ts +3 -0
- package/dist/tools/status.d.ts.map +1 -0
- package/dist/tools/status.js +109 -0
- package/dist/tools/status.js.map +1 -0
- package/dist/tools/supplier-invoices.d.ts +3 -0
- package/dist/tools/supplier-invoices.d.ts.map +1 -0
- package/dist/tools/supplier-invoices.js +88 -0
- package/dist/tools/supplier-invoices.js.map +1 -0
- package/dist/tools/suppliers.d.ts +3 -0
- package/dist/tools/suppliers.d.ts.map +1 -0
- package/dist/tools/suppliers.js +77 -0
- package/dist/tools/suppliers.js.map +1 -0
- package/dist/tools/tax.d.ts +3 -0
- package/dist/tools/tax.d.ts.map +1 -0
- package/dist/tools/tax.js +18 -0
- package/dist/tools/tax.js.map +1 -0
- package/dist/views.d.ts +19 -0
- package/dist/views.d.ts.map +1 -0
- package/dist/views.js +165 -0
- package/dist/views.js.map +1 -0
- package/package.json +60 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, Option } from 'commander';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { isJsonMode, outputList, outputDetail, outputConfirmation, formatTaxReport, formatFinancialReport, } from './formatter.js';
|
|
6
|
+
import { invoiceListColumns, invoiceDetailColumns, invoiceConfirmColumns, customerListColumns, customerDetailColumns, voucherListColumns, voucherDetailColumns, voucherRowColumns, accountListColumns, companyDetailColumns, articleListColumns, articleDetailColumns, supplierListColumns, supplierDetailColumns, supplierInvoiceListColumns, supplierInvoiceDetailColumns, supplierInvoiceConfirmColumns, } from './views.js';
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program
|
|
9
|
+
.name('noxctl')
|
|
10
|
+
.description('CLI and MCP server for Fortnox accounting')
|
|
11
|
+
.version('0.1.0')
|
|
12
|
+
.addOption(new Option('-o, --output <format>', 'Output format')
|
|
13
|
+
.choices(['json', 'table'])
|
|
14
|
+
.default(undefined));
|
|
15
|
+
function json() {
|
|
16
|
+
return isJsonMode(program.opts());
|
|
17
|
+
}
|
|
18
|
+
async function confirmMutation(action, opts, payload) {
|
|
19
|
+
if (opts.dryRun) {
|
|
20
|
+
console.log(`Dry run: ${action}. No request was sent to Fortnox.`);
|
|
21
|
+
if (payload !== undefined) {
|
|
22
|
+
console.log('\nRequest payload:');
|
|
23
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (opts.yes)
|
|
28
|
+
return true;
|
|
29
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
30
|
+
throw new Error(`Confirmation required to ${action}. Re-run with --yes, or --dry-run first.`);
|
|
31
|
+
}
|
|
32
|
+
const rl = createInterface({
|
|
33
|
+
input: process.stdin,
|
|
34
|
+
output: process.stdout,
|
|
35
|
+
});
|
|
36
|
+
try {
|
|
37
|
+
const answer = await rl.question(`${action}. Continue? [y/N] `);
|
|
38
|
+
return ['y', 'yes'].includes(answer.trim().toLowerCase());
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
rl.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// --- init (interactive setup wizard) ---
|
|
45
|
+
program
|
|
46
|
+
.command('init')
|
|
47
|
+
.description('Interactive setup wizard — recommended onboarding path')
|
|
48
|
+
.action(async () => {
|
|
49
|
+
const { loadCredentials, runOAuthSetup } = await import('./auth.js');
|
|
50
|
+
// Step 1: Check if already configured
|
|
51
|
+
const existing = await loadCredentials();
|
|
52
|
+
if (existing) {
|
|
53
|
+
console.log('Existing credentials found.');
|
|
54
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
55
|
+
const rlExisting = createInterface({
|
|
56
|
+
input: process.stdin,
|
|
57
|
+
output: process.stdout,
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
const answer = (await rlExisting.question('Re-run setup? This will replace your current credentials. [y/N] '))
|
|
61
|
+
.trim()
|
|
62
|
+
.toLowerCase();
|
|
63
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
64
|
+
console.log('Run `noxctl company info` to verify your current connection.');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
rlExisting.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
console.log('Run `noxctl company info` to verify, or re-run interactively to reconfigure.');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Step 2: Welcome message
|
|
78
|
+
console.log('Welcome to noxctl init!');
|
|
79
|
+
console.log('');
|
|
80
|
+
console.log("You'll need a Fortnox app from developer.fortnox.se with:");
|
|
81
|
+
console.log(' - Redirect URI: http://localhost:9876/callback');
|
|
82
|
+
console.log(' - Scopes (Behörigheter): Artikel, Bokföring, Faktura, Företagsinformation, Inställningar, Kund, Leverantör, Leverantörsfaktura');
|
|
83
|
+
console.log(' - Service account enabled (recommended)');
|
|
84
|
+
console.log('');
|
|
85
|
+
console.log('See the README for detailed portal instructions.');
|
|
86
|
+
console.log('');
|
|
87
|
+
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
88
|
+
let clientId;
|
|
89
|
+
let clientSecret;
|
|
90
|
+
let serviceAccount;
|
|
91
|
+
if (!isTTY) {
|
|
92
|
+
// CI / non-interactive mode: fall back to env vars
|
|
93
|
+
clientId = process.env.FORTNOX_CLIENT_ID ?? '';
|
|
94
|
+
clientSecret = process.env.FORTNOX_CLIENT_SECRET ?? '';
|
|
95
|
+
serviceAccount = process.env.FORTNOX_SERVICE_ACCOUNT === '1';
|
|
96
|
+
if (!clientId || !clientSecret) {
|
|
97
|
+
console.error('Error: stdin is not a TTY. Set FORTNOX_CLIENT_ID and FORTNOX_CLIENT_SECRET env vars to run non-interactively.');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
let rl = createInterface({
|
|
103
|
+
input: process.stdin,
|
|
104
|
+
output: process.stdout,
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
// Step 3: Prompt for Client ID
|
|
108
|
+
const envClientId = process.env.FORTNOX_CLIENT_ID;
|
|
109
|
+
const clientIdPrompt = envClientId ? `Client ID [${envClientId}]: ` : 'Client ID: ';
|
|
110
|
+
const clientIdAnswer = (await rl.question(clientIdPrompt)).trim();
|
|
111
|
+
clientId = clientIdAnswer || envClientId || '';
|
|
112
|
+
if (!clientId) {
|
|
113
|
+
console.error('Error: Client ID is required.');
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
// Step 4: Prompt for Client Secret (masked input)
|
|
117
|
+
const envClientSecret = process.env.FORTNOX_CLIENT_SECRET;
|
|
118
|
+
if (envClientSecret) {
|
|
119
|
+
process.stdout.write('Client Secret [env var set — press Enter to use it]: ');
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
process.stdout.write('Client Secret: ');
|
|
123
|
+
}
|
|
124
|
+
// Temporarily close rl so we can use raw mode for masked input
|
|
125
|
+
rl.close();
|
|
126
|
+
const clientSecretAnswer = await new Promise((resolve) => {
|
|
127
|
+
let buf = '';
|
|
128
|
+
const stdin = process.stdin;
|
|
129
|
+
stdin.setRawMode(true);
|
|
130
|
+
stdin.resume();
|
|
131
|
+
stdin.setEncoding('utf-8');
|
|
132
|
+
const onData = (chunk) => {
|
|
133
|
+
for (const ch of chunk) {
|
|
134
|
+
if (ch === '\r' || ch === '\n') {
|
|
135
|
+
stdin.setRawMode(false);
|
|
136
|
+
stdin.removeListener('data', onData);
|
|
137
|
+
process.stdout.write('\n');
|
|
138
|
+
resolve(buf.trim());
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
else if (ch === '\u007f' || ch === '\b') {
|
|
142
|
+
if (buf.length > 0) {
|
|
143
|
+
buf = buf.slice(0, -1);
|
|
144
|
+
process.stdout.write('\b \b');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else if (ch === '\u0003') {
|
|
148
|
+
// Ctrl-C
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
buf += ch;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
stdin.on('data', onData);
|
|
157
|
+
});
|
|
158
|
+
// Re-create rl for subsequent questions
|
|
159
|
+
rl = createInterface({
|
|
160
|
+
input: process.stdin,
|
|
161
|
+
output: process.stdout,
|
|
162
|
+
});
|
|
163
|
+
clientSecret = clientSecretAnswer || envClientSecret || '';
|
|
164
|
+
if (!clientSecret) {
|
|
165
|
+
console.error('Error: Client Secret is required.');
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
// Step 5: Service account question — default yes
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log('Service account mode lets noxctl refresh tokens automatically without');
|
|
171
|
+
console.log('opening a browser each time. Enable it in the Fortnox developer portal');
|
|
172
|
+
console.log('under your app\'s OAuth settings ("Möjliggör auktorisering som servicekonto").');
|
|
173
|
+
console.log('');
|
|
174
|
+
const saAnswer = (await rl.question('Is service account mode enabled for your app? [Y/n] '))
|
|
175
|
+
.trim()
|
|
176
|
+
.toLowerCase();
|
|
177
|
+
serviceAccount = saAnswer === '' || saAnswer === 'y' || saAnswer === 'yes';
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
rl.close();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Step 6: Run OAuth flow
|
|
184
|
+
await runOAuthSetup({ clientId, clientSecret, serviceAccount });
|
|
185
|
+
// Step 7: Verify by fetching company info
|
|
186
|
+
try {
|
|
187
|
+
const { getCompanyInfo } = await import('./operations/company.js');
|
|
188
|
+
const data = await getCompanyInfo();
|
|
189
|
+
const company = data;
|
|
190
|
+
console.log('');
|
|
191
|
+
console.log('Connected successfully!');
|
|
192
|
+
if (company['CompanyName']) {
|
|
193
|
+
console.log(` Company: ${company['CompanyName']}`);
|
|
194
|
+
}
|
|
195
|
+
if (company['OrganizationNumber']) {
|
|
196
|
+
console.log(` Org number: ${company['OrganizationNumber']}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
console.log('');
|
|
201
|
+
console.log('OAuth completed. Could not verify company info — you can run `noxctl company info` manually.');
|
|
202
|
+
}
|
|
203
|
+
// Step 8: Offer to register MCP server with Claude Code
|
|
204
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
205
|
+
const rl2 = createInterface({
|
|
206
|
+
input: process.stdin,
|
|
207
|
+
output: process.stdout,
|
|
208
|
+
});
|
|
209
|
+
try {
|
|
210
|
+
// Detect whether we're running via npx or from a local build.
|
|
211
|
+
const argv0 = process.argv[1] ?? '';
|
|
212
|
+
const useNpx = argv0.includes('npx') || argv0.includes('.bin/noxctl');
|
|
213
|
+
console.log('');
|
|
214
|
+
const mcpAnswer = (await rl2.question('Register the MCP server with Claude Code? [Y/n] '))
|
|
215
|
+
.trim()
|
|
216
|
+
.toLowerCase();
|
|
217
|
+
const doRegister = mcpAnswer === '' || mcpAnswer === 'y' || mcpAnswer === 'yes';
|
|
218
|
+
if (doRegister) {
|
|
219
|
+
const { execFile } = await import('node:child_process');
|
|
220
|
+
// All arguments below are static constants — no user input is interpolated.
|
|
221
|
+
const mcpArgs = useNpx
|
|
222
|
+
? ['mcp', 'add', 'fortnox', '--', 'npx', 'noxctl', 'serve']
|
|
223
|
+
: ['mcp', 'add', 'fortnox', '--', 'node', argv0, 'serve'];
|
|
224
|
+
await new Promise((resolve) => {
|
|
225
|
+
execFile('claude', mcpArgs, (err) => {
|
|
226
|
+
if (err) {
|
|
227
|
+
const fallbackCmd = ['claude', ...mcpArgs].join(' ');
|
|
228
|
+
console.log('Could not register automatically. Run this manually:');
|
|
229
|
+
console.log(` ${fallbackCmd}`);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
console.log('MCP server registered. Restart Claude Code to pick it up.');
|
|
233
|
+
}
|
|
234
|
+
resolve();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
// Offer npm link for local clone users so `noxctl` is in PATH
|
|
239
|
+
if (!useNpx) {
|
|
240
|
+
console.log('');
|
|
241
|
+
const linkAnswer = (await rl2.question('Add `noxctl` to your PATH via npm link? [Y/n] '))
|
|
242
|
+
.trim()
|
|
243
|
+
.toLowerCase();
|
|
244
|
+
const doLink = linkAnswer === '' || linkAnswer === 'y' || linkAnswer === 'yes';
|
|
245
|
+
if (doLink) {
|
|
246
|
+
const { execFile: execFileLink } = await import('node:child_process');
|
|
247
|
+
await new Promise((resolve) => {
|
|
248
|
+
execFileLink('npm', ['link'], { cwd: process.cwd() }, (err) => {
|
|
249
|
+
if (err) {
|
|
250
|
+
console.log('Could not link automatically. Run this manually:');
|
|
251
|
+
console.log(' npm link');
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
console.log('Done! `noxctl` is now available globally.');
|
|
255
|
+
}
|
|
256
|
+
resolve();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
rl2.close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
// --- logout ---
|
|
268
|
+
program
|
|
269
|
+
.command('logout')
|
|
270
|
+
.description('Remove stored Fortnox credentials')
|
|
271
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
272
|
+
.action(async (opts) => {
|
|
273
|
+
const { loadCredentials } = await import('./auth.js');
|
|
274
|
+
const { deleteCredentialBlob } = await import('./credentials-store.js');
|
|
275
|
+
const existing = await loadCredentials();
|
|
276
|
+
if (!existing) {
|
|
277
|
+
console.log('No credentials found. Nothing to remove.');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (!(await confirmMutation('Remove stored Fortnox credentials', opts))) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const deleted = await deleteCredentialBlob();
|
|
284
|
+
if (deleted) {
|
|
285
|
+
console.log('Credentials removed.');
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
console.log('Could not remove credentials from the system keychain.');
|
|
289
|
+
console.log('They may have already been removed, or you may need to remove them manually.');
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
// --- doctor ---
|
|
293
|
+
program
|
|
294
|
+
.command('doctor')
|
|
295
|
+
.description('Check setup: credentials, token, API connectivity, and scopes')
|
|
296
|
+
.action(async () => {
|
|
297
|
+
const { loadCredentials } = await import('./auth.js');
|
|
298
|
+
let ok = true;
|
|
299
|
+
function pass(label, detail) {
|
|
300
|
+
console.log(` ✓ ${label}${detail ? ` — ${detail}` : ''}`);
|
|
301
|
+
}
|
|
302
|
+
function fail(label, detail) {
|
|
303
|
+
ok = false;
|
|
304
|
+
console.log(` ✗ ${label}${detail ? ` — ${detail}` : ''}`);
|
|
305
|
+
}
|
|
306
|
+
console.log('Checking noxctl configuration...\n');
|
|
307
|
+
// 1. Node version
|
|
308
|
+
const nodeVersion = process.versions.node;
|
|
309
|
+
const major = parseInt(nodeVersion.split('.')[0], 10);
|
|
310
|
+
if (major >= 20) {
|
|
311
|
+
pass('Node.js', `v${nodeVersion}`);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
fail('Node.js', `v${nodeVersion} (need 20+)`);
|
|
315
|
+
}
|
|
316
|
+
// 2. Credential store backend
|
|
317
|
+
const storeBackend = process.platform === 'darwin'
|
|
318
|
+
? 'macOS Keychain'
|
|
319
|
+
: process.platform === 'win32'
|
|
320
|
+
? 'Windows DPAPI'
|
|
321
|
+
: 'Linux Secret Service';
|
|
322
|
+
pass('Credential store', storeBackend);
|
|
323
|
+
// 3. Credentials exist
|
|
324
|
+
const creds = await loadCredentials();
|
|
325
|
+
if (!creds) {
|
|
326
|
+
fail('Credentials', 'not found — run `noxctl init` to set up');
|
|
327
|
+
console.log(`\n${ok ? 'All checks passed.' : 'Some checks failed.'}`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
pass('Credentials', 'found');
|
|
331
|
+
// 4. Client ID present
|
|
332
|
+
if (creds.client_id) {
|
|
333
|
+
pass('Client ID', `${creds.client_id.slice(0, 8)}...`);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
fail('Client ID', 'missing');
|
|
337
|
+
}
|
|
338
|
+
// 5. Tenant ID (service account)
|
|
339
|
+
if (creds.tenant_id) {
|
|
340
|
+
pass('Service account', `tenant ${creds.tenant_id}`);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
pass('Service account', 'not configured (using refresh token flow)');
|
|
344
|
+
}
|
|
345
|
+
// 6. Token expiry
|
|
346
|
+
const now = Date.now();
|
|
347
|
+
if (creds.expires_at > now) {
|
|
348
|
+
const minutesLeft = Math.round((creds.expires_at - now) / 60000);
|
|
349
|
+
pass('Access token', `valid for ~${minutesLeft} min`);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
pass('Access token', 'expired (will auto-refresh on next request)');
|
|
353
|
+
}
|
|
354
|
+
// 7. API connectivity — try fetching company info
|
|
355
|
+
try {
|
|
356
|
+
const { getCompanyInfo } = await import('./operations/company.js');
|
|
357
|
+
const data = await getCompanyInfo();
|
|
358
|
+
const company = data;
|
|
359
|
+
const name = company['CompanyName'] || 'unknown';
|
|
360
|
+
pass('API connection', `${name}`);
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
364
|
+
fail('API connection', msg);
|
|
365
|
+
console.log(`\n${ok ? 'All checks passed.' : 'Some checks failed.'}`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
// 8. Scope validation — probe each scope with a lightweight GET
|
|
369
|
+
const { SCOPES } = await import('./auth.js');
|
|
370
|
+
const { fortnoxRequest, FortnoxApiError } = await import('./fortnox-client.js');
|
|
371
|
+
const scopeEndpoints = {
|
|
372
|
+
article: 'articles?limit=1',
|
|
373
|
+
customer: 'customers?limit=1',
|
|
374
|
+
invoice: 'invoices?limit=1',
|
|
375
|
+
supplier: 'suppliers?limit=1',
|
|
376
|
+
supplierinvoice: 'supplierinvoices?limit=1',
|
|
377
|
+
bookkeeping: 'vouchers?limit=1',
|
|
378
|
+
companyinformation: 'companyinformation',
|
|
379
|
+
settings: 'settings/company',
|
|
380
|
+
};
|
|
381
|
+
const required = SCOPES.split(' ');
|
|
382
|
+
const missing = [];
|
|
383
|
+
for (const scope of required) {
|
|
384
|
+
const endpoint = scopeEndpoints[scope];
|
|
385
|
+
if (!endpoint)
|
|
386
|
+
continue;
|
|
387
|
+
try {
|
|
388
|
+
await fortnoxRequest(endpoint);
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
if (err instanceof FortnoxApiError && err.statusCode === 403) {
|
|
392
|
+
missing.push(scope);
|
|
393
|
+
}
|
|
394
|
+
// Non-403 errors (e.g. 500) are not scope problems — ignore here
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (missing.length === 0) {
|
|
398
|
+
pass('Scopes', `all ${required.length} scopes authorized`);
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
fail('Scopes', `missing: ${missing.join(', ')}. Enable them in your Fortnox app at developer.fortnox.se`);
|
|
402
|
+
}
|
|
403
|
+
console.log(`\n${ok ? 'All checks passed.' : 'Some checks failed.'}`);
|
|
404
|
+
});
|
|
405
|
+
// --- serve (default command) ---
|
|
406
|
+
program
|
|
407
|
+
.command('serve', { isDefault: true })
|
|
408
|
+
.description('Start the MCP server (stdio transport)')
|
|
409
|
+
.action(async () => {
|
|
410
|
+
const { startMcpServer } = await import('./index.js');
|
|
411
|
+
await startMcpServer();
|
|
412
|
+
});
|
|
413
|
+
// --- invoices ---
|
|
414
|
+
const invoices = program.command('invoices').description('Invoice operations');
|
|
415
|
+
invoices
|
|
416
|
+
.command('list')
|
|
417
|
+
.description('List/filter invoices')
|
|
418
|
+
.option('--filter <filter>', 'Filter: cancelled, fullypaid, unpaid, unpaidoverdue, unbooked')
|
|
419
|
+
.option('--customer <number>', 'Filter by customer number')
|
|
420
|
+
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
421
|
+
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
422
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
423
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
424
|
+
.option('-a, --all', 'Fetch all pages')
|
|
425
|
+
.action(async (opts) => {
|
|
426
|
+
const { listInvoices } = await import('./operations/invoices.js');
|
|
427
|
+
const data = await listInvoices({
|
|
428
|
+
filter: opts.filter,
|
|
429
|
+
customerNumber: opts.customer,
|
|
430
|
+
fromDate: opts.from,
|
|
431
|
+
toDate: opts.to,
|
|
432
|
+
page: opts.page,
|
|
433
|
+
limit: opts.limit,
|
|
434
|
+
all: opts.all,
|
|
435
|
+
});
|
|
436
|
+
const envelope = data;
|
|
437
|
+
outputList(envelope.Invoices ?? [], invoiceListColumns, json(), data, envelope.MetaInformation);
|
|
438
|
+
});
|
|
439
|
+
invoices
|
|
440
|
+
.command('get <documentNumber>')
|
|
441
|
+
.description('Get a single invoice')
|
|
442
|
+
.action(async (documentNumber) => {
|
|
443
|
+
const { getInvoice } = await import('./operations/invoices.js');
|
|
444
|
+
const data = await getInvoice(documentNumber);
|
|
445
|
+
outputDetail(data, invoiceDetailColumns, json());
|
|
446
|
+
});
|
|
447
|
+
invoices
|
|
448
|
+
.command('create')
|
|
449
|
+
.description('Create an invoice')
|
|
450
|
+
.requiredOption('--customer <number>', 'Customer number')
|
|
451
|
+
.requiredOption('--input <file>', 'Invoice data as JSON file (or - for stdin)')
|
|
452
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
453
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
454
|
+
.addHelpText('after', `
|
|
455
|
+
Examples:
|
|
456
|
+
echo '{"InvoiceRows":[{"ArticleNumber":"1","DeliveredQuantity":10,"Price":1500}]}' | noxctl invoices create --customer 25 --input - --dry-run
|
|
457
|
+
|
|
458
|
+
# Minimal JSON (Description instead of ArticleNumber):
|
|
459
|
+
echo '{"InvoiceRows":[{"Description":"Consulting","DeliveredQuantity":8,"Price":1200,"AccountNumber":3001,"VAT":25}]}' | noxctl invoices create --customer 25 --input - --yes`)
|
|
460
|
+
.action(async (opts) => {
|
|
461
|
+
const { createInvoice } = await import('./operations/invoices.js');
|
|
462
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
463
|
+
const input = JSON.parse(raw);
|
|
464
|
+
const params = { CustomerNumber: opts.customer, ...input };
|
|
465
|
+
if (!(await confirmMutation(`Create invoice for customer ${opts.customer}`, opts, {
|
|
466
|
+
Invoice: params,
|
|
467
|
+
}))) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const data = await createInvoice(params);
|
|
471
|
+
outputDetail(data, invoiceDetailColumns, json());
|
|
472
|
+
});
|
|
473
|
+
invoices
|
|
474
|
+
.command('update <documentNumber>')
|
|
475
|
+
.description('Update an invoice (not yet bookkeept)')
|
|
476
|
+
.requiredOption('--input <file>', 'Invoice data as JSON file (or - for stdin)')
|
|
477
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
478
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
479
|
+
.addHelpText('after', `
|
|
480
|
+
Examples:
|
|
481
|
+
echo '{"DueDate":"2026-04-30","OurReference":"Casey Example"}' | noxctl invoices update 28 --input - --dry-run
|
|
482
|
+
echo '{"InvoiceRows":[{"ArticleNumber":"1","DeliveredQuantity":5,"Price":2000}]}' | noxctl invoices update 28 --input - --yes`)
|
|
483
|
+
.action(async (documentNumber, opts) => {
|
|
484
|
+
const { updateInvoice } = await import('./operations/invoices.js');
|
|
485
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
486
|
+
const fields = JSON.parse(raw);
|
|
487
|
+
if (!(await confirmMutation(`Update invoice ${documentNumber}`, opts, { Invoice: fields }))) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const data = await updateInvoice(documentNumber, fields);
|
|
491
|
+
outputDetail(data, invoiceDetailColumns, json());
|
|
492
|
+
});
|
|
493
|
+
invoices
|
|
494
|
+
.command('send <documentNumber>')
|
|
495
|
+
.description('Send an invoice')
|
|
496
|
+
.addOption(new Option('--method <method>', 'Send method: email, print, einvoice')
|
|
497
|
+
.choices(['email', 'print', 'einvoice'])
|
|
498
|
+
.default('email'))
|
|
499
|
+
.option('--subject <subject>', 'Email subject (default: keeps existing)')
|
|
500
|
+
.option('--body <body>', 'Email body text')
|
|
501
|
+
.option('--bcc <email>', 'BCC email address')
|
|
502
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
503
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
504
|
+
.action(async (documentNumber, opts) => {
|
|
505
|
+
const { sendInvoice } = await import('./operations/invoices.js');
|
|
506
|
+
if (!(await confirmMutation(`Send invoice ${documentNumber} via ${opts.method}`, opts))) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const emailOptions = opts.subject || opts.body || opts.bcc
|
|
510
|
+
? { subject: opts.subject, body: opts.body, bcc: opts.bcc }
|
|
511
|
+
: undefined;
|
|
512
|
+
const data = await sendInvoice(documentNumber, opts.method, emailOptions);
|
|
513
|
+
outputConfirmation(`Invoice ${documentNumber} sent via ${opts.method}.`, json(), data, invoiceConfirmColumns);
|
|
514
|
+
});
|
|
515
|
+
invoices
|
|
516
|
+
.command('bookkeep <documentNumber>')
|
|
517
|
+
.description('Bookkeep an invoice')
|
|
518
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
519
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
520
|
+
.action(async (documentNumber, opts) => {
|
|
521
|
+
const { bookkeepInvoice } = await import('./operations/invoices.js');
|
|
522
|
+
if (!(await confirmMutation(`Bookkeep invoice ${documentNumber}`, opts))) {
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const data = await bookkeepInvoice(documentNumber);
|
|
526
|
+
outputConfirmation(`Invoice ${documentNumber} bookkeept.`, json(), data, invoiceConfirmColumns);
|
|
527
|
+
});
|
|
528
|
+
invoices
|
|
529
|
+
.command('credit <documentNumber>')
|
|
530
|
+
.description('Credit an invoice')
|
|
531
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
532
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
533
|
+
.action(async (documentNumber, opts) => {
|
|
534
|
+
const { creditInvoice } = await import('./operations/invoices.js');
|
|
535
|
+
if (!(await confirmMutation(`Credit invoice ${documentNumber}`, opts))) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const data = await creditInvoice(documentNumber);
|
|
539
|
+
outputConfirmation(`Invoice ${documentNumber} credited.`, json(), data, invoiceConfirmColumns);
|
|
540
|
+
});
|
|
541
|
+
// --- tax ---
|
|
542
|
+
const tax = program.command('tax').description('Tax operations');
|
|
543
|
+
tax
|
|
544
|
+
.command('report')
|
|
545
|
+
.description('Generate VAT tax report for a period')
|
|
546
|
+
.requiredOption('--from <date>', 'From date (YYYY-MM-DD)')
|
|
547
|
+
.requiredOption('--to <date>', 'To date (YYYY-MM-DD)')
|
|
548
|
+
.option('--year <number>', 'Financial year', parseInt)
|
|
549
|
+
.action(async (opts) => {
|
|
550
|
+
const { generateTaxReport } = await import('./operations/tax.js');
|
|
551
|
+
const data = await generateTaxReport({
|
|
552
|
+
fromDate: opts.from,
|
|
553
|
+
toDate: opts.to,
|
|
554
|
+
financialYear: opts.year,
|
|
555
|
+
});
|
|
556
|
+
if (json()) {
|
|
557
|
+
console.log(JSON.stringify(data, null, 2));
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
console.log(formatTaxReport(data));
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
// --- reports ---
|
|
564
|
+
const reports = program
|
|
565
|
+
.command('reports')
|
|
566
|
+
.description('Financial reports (resultat/balansräkning)');
|
|
567
|
+
reports
|
|
568
|
+
.command('income')
|
|
569
|
+
.alias('resultat')
|
|
570
|
+
.description('Income statement (resultaträkning)')
|
|
571
|
+
.option('--year <number>', 'Financial year', parseInt)
|
|
572
|
+
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
573
|
+
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
574
|
+
.action(async (opts) => {
|
|
575
|
+
const { getIncomeStatement } = await import('./operations/financial-reports.js');
|
|
576
|
+
const data = await getIncomeStatement({
|
|
577
|
+
financialYear: opts.year,
|
|
578
|
+
fromDate: opts.from,
|
|
579
|
+
toDate: opts.to,
|
|
580
|
+
});
|
|
581
|
+
if (json()) {
|
|
582
|
+
console.log(JSON.stringify(data, null, 2));
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
console.log(formatFinancialReport(data));
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
reports
|
|
589
|
+
.command('balance')
|
|
590
|
+
.alias('balans')
|
|
591
|
+
.description('Balance sheet (balansräkning)')
|
|
592
|
+
.option('--year <number>', 'Financial year', parseInt)
|
|
593
|
+
.option('--to <date>', 'As-of date (YYYY-MM-DD)')
|
|
594
|
+
.action(async (opts) => {
|
|
595
|
+
const { getBalanceSheet } = await import('./operations/financial-reports.js');
|
|
596
|
+
const data = await getBalanceSheet({ financialYear: opts.year, toDate: opts.to });
|
|
597
|
+
if (json()) {
|
|
598
|
+
console.log(JSON.stringify(data, null, 2));
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
console.log(formatFinancialReport(data));
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
// --- accounts ---
|
|
605
|
+
const accounts = program.command('accounts').description('Chart of accounts operations');
|
|
606
|
+
accounts
|
|
607
|
+
.command('list')
|
|
608
|
+
.description('List accounts')
|
|
609
|
+
.option('--search <term>', 'Search by account name or number')
|
|
610
|
+
.option('--year <number>', 'Financial year', parseInt)
|
|
611
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
612
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
613
|
+
.option('-a, --all', 'Fetch all pages')
|
|
614
|
+
.action(async (opts) => {
|
|
615
|
+
const { listAccounts } = await import('./operations/accounts.js');
|
|
616
|
+
const data = await listAccounts({
|
|
617
|
+
search: opts.search,
|
|
618
|
+
financialYear: opts.year,
|
|
619
|
+
page: opts.page,
|
|
620
|
+
limit: opts.limit,
|
|
621
|
+
all: opts.all,
|
|
622
|
+
});
|
|
623
|
+
outputList(data.Accounts ?? [], accountListColumns, json(), data, data.MetaInformation);
|
|
624
|
+
});
|
|
625
|
+
// --- customers ---
|
|
626
|
+
const customers = program.command('customers').description('Customer operations');
|
|
627
|
+
customers
|
|
628
|
+
.command('list')
|
|
629
|
+
.description('List/search customers')
|
|
630
|
+
.option('--search <term>', 'Search by name')
|
|
631
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
632
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
633
|
+
.option('-a, --all', 'Fetch all pages')
|
|
634
|
+
.action(async (opts) => {
|
|
635
|
+
const { listCustomers } = await import('./operations/customers.js');
|
|
636
|
+
const data = await listCustomers({
|
|
637
|
+
search: opts.search,
|
|
638
|
+
page: opts.page,
|
|
639
|
+
limit: opts.limit,
|
|
640
|
+
all: opts.all,
|
|
641
|
+
});
|
|
642
|
+
const envelope = data;
|
|
643
|
+
outputList(envelope.Customers ?? [], customerListColumns, json(), data, envelope.MetaInformation);
|
|
644
|
+
});
|
|
645
|
+
customers
|
|
646
|
+
.command('get <customerNumber>')
|
|
647
|
+
.description('Get a single customer')
|
|
648
|
+
.action(async (customerNumber) => {
|
|
649
|
+
const { getCustomer } = await import('./operations/customers.js');
|
|
650
|
+
const data = await getCustomer(customerNumber);
|
|
651
|
+
outputDetail(data, customerDetailColumns, json());
|
|
652
|
+
});
|
|
653
|
+
customers
|
|
654
|
+
.command('create')
|
|
655
|
+
.description('Create a customer')
|
|
656
|
+
.requiredOption('--name <name>', 'Customer name')
|
|
657
|
+
.option('--input <file>', 'Customer data as JSON file (or - for stdin)')
|
|
658
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
659
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
660
|
+
.addHelpText('after', `
|
|
661
|
+
Examples:
|
|
662
|
+
noxctl customers create --name "Acme AB" --yes
|
|
663
|
+
echo '{"OrganisationNumber":"556677-8899","Email":"info@acme.se","City":"Stockholm"}' | noxctl customers create --name "Acme AB" --input - --yes`)
|
|
664
|
+
.action(async (opts) => {
|
|
665
|
+
const { createCustomer } = await import('./operations/customers.js');
|
|
666
|
+
let input = {};
|
|
667
|
+
if (opts.input) {
|
|
668
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
669
|
+
input = JSON.parse(raw);
|
|
670
|
+
}
|
|
671
|
+
const params = { ...input, Name: opts.name };
|
|
672
|
+
if (!(await confirmMutation(`Create customer "${opts.name}"`, opts, { Customer: params }))) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const data = await createCustomer(params);
|
|
676
|
+
outputDetail(data, customerDetailColumns, json());
|
|
677
|
+
});
|
|
678
|
+
customers
|
|
679
|
+
.command('update <customerNumber>')
|
|
680
|
+
.description('Update a customer')
|
|
681
|
+
.requiredOption('--input <file>', 'Customer data as JSON file (or - for stdin)')
|
|
682
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
683
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
684
|
+
.addHelpText('after', `
|
|
685
|
+
Examples:
|
|
686
|
+
echo '{"Email":"new@acme.se","Phone":"08-123456"}' | noxctl customers update 25 --input - --yes`)
|
|
687
|
+
.action(async (customerNumber, opts) => {
|
|
688
|
+
const { updateCustomer } = await import('./operations/customers.js');
|
|
689
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
690
|
+
const fields = JSON.parse(raw);
|
|
691
|
+
if (!(await confirmMutation(`Update customer ${customerNumber}`, opts, { Customer: fields }))) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const data = await updateCustomer(customerNumber, fields);
|
|
695
|
+
outputDetail(data, customerDetailColumns, json());
|
|
696
|
+
});
|
|
697
|
+
// --- articles ---
|
|
698
|
+
const articles = program.command('articles').description('Article operations');
|
|
699
|
+
articles
|
|
700
|
+
.command('list')
|
|
701
|
+
.description('List/search articles')
|
|
702
|
+
.option('--search <term>', 'Search by description')
|
|
703
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
704
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
705
|
+
.option('-a, --all', 'Fetch all pages')
|
|
706
|
+
.action(async (opts) => {
|
|
707
|
+
const { listArticles } = await import('./operations/articles.js');
|
|
708
|
+
const data = await listArticles({
|
|
709
|
+
search: opts.search,
|
|
710
|
+
page: opts.page,
|
|
711
|
+
limit: opts.limit,
|
|
712
|
+
all: opts.all,
|
|
713
|
+
});
|
|
714
|
+
const envelope = data;
|
|
715
|
+
outputList(envelope.Articles ?? [], articleListColumns, json(), data, envelope.MetaInformation);
|
|
716
|
+
});
|
|
717
|
+
articles
|
|
718
|
+
.command('get <articleNumber>')
|
|
719
|
+
.description('Get a single article')
|
|
720
|
+
.action(async (articleNumber) => {
|
|
721
|
+
const { getArticle } = await import('./operations/articles.js');
|
|
722
|
+
const data = await getArticle(articleNumber);
|
|
723
|
+
outputDetail(data, articleDetailColumns, json());
|
|
724
|
+
});
|
|
725
|
+
articles
|
|
726
|
+
.command('create')
|
|
727
|
+
.description('Create an article')
|
|
728
|
+
.requiredOption('--description <text>', 'Article description')
|
|
729
|
+
.option('--article-number <number>', 'Article number (auto-generated if omitted)')
|
|
730
|
+
.option('--input <file>', 'Article data as JSON file (or - for stdin)')
|
|
731
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
732
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
733
|
+
.addHelpText('after', `
|
|
734
|
+
Examples:
|
|
735
|
+
noxctl articles create --description "Konsulttimme" --yes
|
|
736
|
+
echo '{"SalesPrice":1500,"Unit":"tim","SalesAccount":3001,"VAT":25}' | noxctl articles create --description "Konsulttimme" --input - --yes`)
|
|
737
|
+
.action(async (opts) => {
|
|
738
|
+
const { createArticle } = await import('./operations/articles.js');
|
|
739
|
+
let input = {};
|
|
740
|
+
if (opts.input) {
|
|
741
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
742
|
+
input = JSON.parse(raw);
|
|
743
|
+
}
|
|
744
|
+
const params = { ...input, Description: opts.description };
|
|
745
|
+
if (opts.articleNumber)
|
|
746
|
+
params.ArticleNumber = opts.articleNumber;
|
|
747
|
+
if (!(await confirmMutation(`Create article "${opts.description}"`, opts, { Article: params }))) {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const data = await createArticle(params);
|
|
751
|
+
outputDetail(data, articleDetailColumns, json());
|
|
752
|
+
});
|
|
753
|
+
articles
|
|
754
|
+
.command('update <articleNumber>')
|
|
755
|
+
.description('Update an article')
|
|
756
|
+
.requiredOption('--input <file>', 'Article data as JSON file (or - for stdin)')
|
|
757
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
758
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
759
|
+
.addHelpText('after', `
|
|
760
|
+
Examples:
|
|
761
|
+
echo '{"SalesPrice":1800}' | noxctl articles update 1 --input - --yes`)
|
|
762
|
+
.action(async (articleNumber, opts) => {
|
|
763
|
+
const { updateArticle } = await import('./operations/articles.js');
|
|
764
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
765
|
+
const fields = JSON.parse(raw);
|
|
766
|
+
if (!(await confirmMutation(`Update article ${articleNumber}`, opts, { Article: fields }))) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const data = await updateArticle(articleNumber, fields);
|
|
770
|
+
outputDetail(data, articleDetailColumns, json());
|
|
771
|
+
});
|
|
772
|
+
// --- suppliers ---
|
|
773
|
+
const suppliers = program.command('suppliers').description('Supplier operations');
|
|
774
|
+
suppliers
|
|
775
|
+
.command('list')
|
|
776
|
+
.description('List/search suppliers')
|
|
777
|
+
.option('--search <term>', 'Search by name')
|
|
778
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
779
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
780
|
+
.option('-a, --all', 'Fetch all pages')
|
|
781
|
+
.action(async (opts) => {
|
|
782
|
+
const { listSuppliers } = await import('./operations/suppliers.js');
|
|
783
|
+
const data = await listSuppliers({
|
|
784
|
+
search: opts.search,
|
|
785
|
+
page: opts.page,
|
|
786
|
+
limit: opts.limit,
|
|
787
|
+
all: opts.all,
|
|
788
|
+
});
|
|
789
|
+
const envelope = data;
|
|
790
|
+
outputList(envelope.Suppliers ?? [], supplierListColumns, json(), data, envelope.MetaInformation);
|
|
791
|
+
});
|
|
792
|
+
suppliers
|
|
793
|
+
.command('get <supplierNumber>')
|
|
794
|
+
.description('Get a single supplier')
|
|
795
|
+
.action(async (supplierNumber) => {
|
|
796
|
+
const { getSupplier } = await import('./operations/suppliers.js');
|
|
797
|
+
const data = await getSupplier(supplierNumber);
|
|
798
|
+
outputDetail(data, supplierDetailColumns, json());
|
|
799
|
+
});
|
|
800
|
+
suppliers
|
|
801
|
+
.command('create')
|
|
802
|
+
.description('Create a supplier')
|
|
803
|
+
.requiredOption('--name <name>', 'Supplier name')
|
|
804
|
+
.option('--input <file>', 'Supplier data as JSON file (or - for stdin)')
|
|
805
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
806
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
807
|
+
.addHelpText('after', `
|
|
808
|
+
Examples:
|
|
809
|
+
noxctl suppliers create --name "Dustin AB" --yes
|
|
810
|
+
echo '{"OrganisationNumber":"556123-4567","BG":"123-4567","Email":"faktura@dustin.se"}' | noxctl suppliers create --name "Dustin AB" --input - --yes`)
|
|
811
|
+
.action(async (opts) => {
|
|
812
|
+
const { createSupplier } = await import('./operations/suppliers.js');
|
|
813
|
+
let input = {};
|
|
814
|
+
if (opts.input) {
|
|
815
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
816
|
+
input = JSON.parse(raw);
|
|
817
|
+
}
|
|
818
|
+
const params = { ...input, Name: opts.name };
|
|
819
|
+
if (!(await confirmMutation(`Create supplier "${opts.name}"`, opts, { Supplier: params }))) {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const data = await createSupplier(params);
|
|
823
|
+
outputDetail(data, supplierDetailColumns, json());
|
|
824
|
+
});
|
|
825
|
+
suppliers
|
|
826
|
+
.command('update <supplierNumber>')
|
|
827
|
+
.description('Update a supplier')
|
|
828
|
+
.requiredOption('--input <file>', 'Supplier data as JSON file (or - for stdin)')
|
|
829
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
830
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
831
|
+
.addHelpText('after', `
|
|
832
|
+
Examples:
|
|
833
|
+
echo '{"Email":"new@dustin.se","BG":"765-4321"}' | noxctl suppliers update 1 --input - --yes`)
|
|
834
|
+
.action(async (supplierNumber, opts) => {
|
|
835
|
+
const { updateSupplier } = await import('./operations/suppliers.js');
|
|
836
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
837
|
+
const fields = JSON.parse(raw);
|
|
838
|
+
if (!(await confirmMutation(`Update supplier ${supplierNumber}`, opts, { Supplier: fields }))) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const data = await updateSupplier(supplierNumber, fields);
|
|
842
|
+
outputDetail(data, supplierDetailColumns, json());
|
|
843
|
+
});
|
|
844
|
+
// --- supplier-invoices ---
|
|
845
|
+
const supplierInvoices = program
|
|
846
|
+
.command('supplier-invoices')
|
|
847
|
+
.alias('si')
|
|
848
|
+
.description('Supplier invoice operations (leverantörsfakturor)');
|
|
849
|
+
supplierInvoices
|
|
850
|
+
.command('list')
|
|
851
|
+
.description('List/filter supplier invoices')
|
|
852
|
+
.option('--filter <filter>', 'Filter: cancelled, fullypaid, unpaid, unpaidoverdue, unbooked, pendingpayment')
|
|
853
|
+
.option('--supplier <number>', 'Filter by supplier number')
|
|
854
|
+
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
855
|
+
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
856
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
857
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
858
|
+
.option('-a, --all', 'Fetch all pages')
|
|
859
|
+
.action(async (opts) => {
|
|
860
|
+
const { listSupplierInvoices } = await import('./operations/supplier-invoices.js');
|
|
861
|
+
const data = await listSupplierInvoices({
|
|
862
|
+
filter: opts.filter,
|
|
863
|
+
supplierNumber: opts.supplier,
|
|
864
|
+
fromDate: opts.from,
|
|
865
|
+
toDate: opts.to,
|
|
866
|
+
page: opts.page,
|
|
867
|
+
limit: opts.limit,
|
|
868
|
+
all: opts.all,
|
|
869
|
+
});
|
|
870
|
+
const envelope = data;
|
|
871
|
+
outputList(envelope.SupplierInvoices ?? [], supplierInvoiceListColumns, json(), data, envelope.MetaInformation);
|
|
872
|
+
});
|
|
873
|
+
supplierInvoices
|
|
874
|
+
.command('get <givenNumber>')
|
|
875
|
+
.description('Get a single supplier invoice')
|
|
876
|
+
.action(async (givenNumber) => {
|
|
877
|
+
const { getSupplierInvoice } = await import('./operations/supplier-invoices.js');
|
|
878
|
+
const data = await getSupplierInvoice(givenNumber);
|
|
879
|
+
outputDetail(data, supplierInvoiceDetailColumns, json());
|
|
880
|
+
});
|
|
881
|
+
supplierInvoices
|
|
882
|
+
.command('create')
|
|
883
|
+
.description('Create a supplier invoice')
|
|
884
|
+
.requiredOption('--supplier <number>', 'Supplier number')
|
|
885
|
+
.requiredOption('--input <file>', 'Invoice data as JSON file (or - for stdin)')
|
|
886
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
887
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
888
|
+
.addHelpText('after', `
|
|
889
|
+
Examples:
|
|
890
|
+
echo '{"InvoiceDate":"2026-03-01","DueDate":"2026-03-30","Total":1250,"OCR":"12345","SupplierInvoiceRows":[{"Account":6570,"Debit":1000,"Credit":0},{"Account":2641,"Debit":250,"Credit":0},{"Account":2440,"Debit":0,"Credit":1250}]}' | noxctl supplier-invoices create --supplier 1 --input - --dry-run`)
|
|
891
|
+
.action(async (opts) => {
|
|
892
|
+
const { createSupplierInvoice } = await import('./operations/supplier-invoices.js');
|
|
893
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
894
|
+
const input = JSON.parse(raw);
|
|
895
|
+
const params = { SupplierNumber: opts.supplier, ...input };
|
|
896
|
+
if (!(await confirmMutation(`Create supplier invoice for supplier ${opts.supplier}`, opts, {
|
|
897
|
+
SupplierInvoice: params,
|
|
898
|
+
}))) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const data = await createSupplierInvoice(params);
|
|
902
|
+
outputDetail(data, supplierInvoiceDetailColumns, json());
|
|
903
|
+
});
|
|
904
|
+
supplierInvoices
|
|
905
|
+
.command('bookkeep <givenNumber>')
|
|
906
|
+
.description('Bookkeep a supplier invoice')
|
|
907
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
908
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
909
|
+
.action(async (givenNumber, opts) => {
|
|
910
|
+
const { bookkeepSupplierInvoice } = await import('./operations/supplier-invoices.js');
|
|
911
|
+
if (!(await confirmMutation(`Bookkeep supplier invoice ${givenNumber}`, opts))) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
const data = await bookkeepSupplierInvoice(givenNumber);
|
|
915
|
+
outputConfirmation(`Supplier invoice ${givenNumber} bookkeept.`, json(), data, supplierInvoiceConfirmColumns);
|
|
916
|
+
});
|
|
917
|
+
// --- company ---
|
|
918
|
+
const company = program.command('company').description('Company operations');
|
|
919
|
+
company
|
|
920
|
+
.command('info')
|
|
921
|
+
.description('Get company information')
|
|
922
|
+
.action(async () => {
|
|
923
|
+
const { getCompanyInfo } = await import('./operations/company.js');
|
|
924
|
+
const data = await getCompanyInfo();
|
|
925
|
+
outputDetail(data, companyDetailColumns, json());
|
|
926
|
+
});
|
|
927
|
+
// --- vouchers ---
|
|
928
|
+
const vouchers = program.command('vouchers').description('Voucher operations');
|
|
929
|
+
vouchers
|
|
930
|
+
.command('list')
|
|
931
|
+
.description('List vouchers')
|
|
932
|
+
.option('--series <series>', 'Voucher series (e.g. "A")')
|
|
933
|
+
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
934
|
+
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
935
|
+
.option('--year <number>', 'Financial year', parseInt)
|
|
936
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
937
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
938
|
+
.option('-a, --all', 'Fetch all pages')
|
|
939
|
+
.action(async (opts) => {
|
|
940
|
+
const { listVouchers } = await import('./operations/vouchers.js');
|
|
941
|
+
const data = await listVouchers({
|
|
942
|
+
series: opts.series,
|
|
943
|
+
fromDate: opts.from,
|
|
944
|
+
toDate: opts.to,
|
|
945
|
+
financialYear: opts.year,
|
|
946
|
+
page: opts.page,
|
|
947
|
+
limit: opts.limit,
|
|
948
|
+
all: opts.all,
|
|
949
|
+
});
|
|
950
|
+
const envelope = data;
|
|
951
|
+
outputList(envelope.Vouchers ?? [], voucherListColumns, json(), data, envelope.MetaInformation);
|
|
952
|
+
});
|
|
953
|
+
vouchers
|
|
954
|
+
.command('get <series> <voucherNumber>')
|
|
955
|
+
.description('Get a single voucher with rows (account, debit, credit)')
|
|
956
|
+
.option('--year <number>', 'Financial year', parseInt)
|
|
957
|
+
.action(async (series, voucherNumber, opts) => {
|
|
958
|
+
const { getVoucher } = await import('./operations/vouchers.js');
|
|
959
|
+
const data = await getVoucher(series, voucherNumber, opts.year);
|
|
960
|
+
if (json()) {
|
|
961
|
+
console.log(JSON.stringify(data, null, 2));
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
outputDetail(data, voucherDetailColumns, false);
|
|
965
|
+
const rows = data.VoucherRows;
|
|
966
|
+
if (rows?.length) {
|
|
967
|
+
console.log('\nRows:');
|
|
968
|
+
outputList(rows, voucherRowColumns, false, rows);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
vouchers
|
|
973
|
+
.command('create')
|
|
974
|
+
.description('Create a voucher')
|
|
975
|
+
.requiredOption('--input <file>', 'Voucher data as JSON file (or - for stdin)')
|
|
976
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
977
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
978
|
+
.addHelpText('after', `
|
|
979
|
+
Examples:
|
|
980
|
+
echo '{"Description":"Bankkostnad","TransactionDate":"2026-03-01","VoucherRows":[{"Account":6570,"Debit":500},{"Account":1930,"Credit":500}]}' | noxctl vouchers create --input - --dry-run`)
|
|
981
|
+
.action(async (opts) => {
|
|
982
|
+
const { createVoucher } = await import('./operations/vouchers.js');
|
|
983
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
984
|
+
const params = JSON.parse(raw);
|
|
985
|
+
if (!(await confirmMutation(`Create voucher "${String(params.Description || '')}"`, opts, {
|
|
986
|
+
Voucher: params,
|
|
987
|
+
}))) {
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const data = await createVoucher(params);
|
|
991
|
+
outputDetail(data, voucherDetailColumns, json());
|
|
992
|
+
});
|
|
993
|
+
// Error handling
|
|
994
|
+
program.exitOverride();
|
|
995
|
+
try {
|
|
996
|
+
await program.parseAsync(process.argv);
|
|
997
|
+
}
|
|
998
|
+
catch (err) {
|
|
999
|
+
if (err instanceof Error && 'code' in err) {
|
|
1000
|
+
const code = err.code;
|
|
1001
|
+
// Commander throws for --help and --version with exitCode 0
|
|
1002
|
+
if (code === 'commander.helpDisplayed' || code === 'commander.version') {
|
|
1003
|
+
process.exit(0);
|
|
1004
|
+
}
|
|
1005
|
+
if (code === 'commander.unknownCommand' || code === 'commander.missingMandatoryOptionValue') {
|
|
1006
|
+
process.exit(1);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
console.error(err instanceof Error ? err.message : err);
|
|
1010
|
+
process.exit(1);
|
|
1011
|
+
}
|
|
1012
|
+
//# sourceMappingURL=cli.js.map
|