noxctl 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +279 -29
- package/dist/auth.d.ts +15 -7
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +128 -22
- package/dist/auth.js.map +1 -1
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1640 -70
- package/dist/cli.js.map +1 -1
- package/dist/completions.d.ts +13 -0
- package/dist/completions.d.ts.map +1 -0
- package/dist/completions.js +150 -0
- package/dist/completions.js.map +1 -0
- package/dist/config-paths.d.ts +2 -0
- package/dist/config-paths.d.ts.map +1 -0
- package/dist/config-paths.js +7 -0
- package/dist/config-paths.js.map +1 -0
- package/dist/credentials-store.d.ts +13 -3
- package/dist/credentials-store.d.ts.map +1 -1
- package/dist/credentials-store.js +197 -62
- package/dist/credentials-store.js.map +1 -1
- package/dist/date-periods.d.ts +14 -0
- package/dist/date-periods.d.ts.map +1 -0
- package/dist/date-periods.js +103 -0
- package/dist/date-periods.js.map +1 -0
- package/dist/formatter.d.ts +11 -2
- package/dist/formatter.d.ts.map +1 -1
- package/dist/formatter.js +27 -4
- package/dist/formatter.js.map +1 -1
- package/dist/fortnox-client.d.ts +1 -0
- package/dist/fortnox-client.d.ts.map +1 -1
- package/dist/fortnox-client.js +28 -5
- package/dist/fortnox-client.js.map +1 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +92 -2
- package/dist/index.js.map +1 -1
- package/dist/keychain-target.d.ts +32 -0
- package/dist/keychain-target.d.ts.map +1 -0
- package/dist/keychain-target.js +349 -0
- package/dist/keychain-target.js.map +1 -0
- package/dist/operations/analytics.d.ts +84 -0
- package/dist/operations/analytics.d.ts.map +1 -0
- package/dist/operations/analytics.js +154 -0
- package/dist/operations/analytics.js.map +1 -0
- package/dist/operations/contracts.d.ts +23 -0
- package/dist/operations/contracts.d.ts.map +1 -0
- package/dist/operations/contracts.js +52 -0
- package/dist/operations/contracts.js.map +1 -0
- package/dist/operations/costcenters.d.ts +20 -0
- package/dist/operations/costcenters.d.ts.map +1 -0
- package/dist/operations/costcenters.js +38 -0
- package/dist/operations/costcenters.js.map +1 -0
- package/dist/operations/customers.d.ts.map +1 -1
- package/dist/operations/customers.js +12 -2
- package/dist/operations/customers.js.map +1 -1
- package/dist/operations/financial-years.d.ts +16 -0
- package/dist/operations/financial-years.d.ts.map +1 -0
- package/dist/operations/financial-years.js +32 -0
- package/dist/operations/financial-years.js.map +1 -0
- package/dist/operations/invoice-payments.d.ts +21 -0
- package/dist/operations/invoice-payments.d.ts.map +1 -0
- package/dist/operations/invoice-payments.js +40 -0
- package/dist/operations/invoice-payments.js.map +1 -0
- package/dist/operations/offers.d.ts +25 -0
- package/dist/operations/offers.d.ts.map +1 -0
- package/dist/operations/offers.js +48 -0
- package/dist/operations/offers.js.map +1 -0
- package/dist/operations/orders.d.ts +24 -0
- package/dist/operations/orders.d.ts.map +1 -0
- package/dist/operations/orders.js +44 -0
- package/dist/operations/orders.js.map +1 -0
- package/dist/operations/pricelists.d.ts +36 -0
- package/dist/operations/pricelists.d.ts.map +1 -0
- package/dist/operations/pricelists.js +52 -0
- package/dist/operations/pricelists.js.map +1 -0
- package/dist/operations/projects.d.ts +19 -0
- package/dist/operations/projects.d.ts.map +1 -0
- package/dist/operations/projects.js +33 -0
- package/dist/operations/projects.js.map +1 -0
- package/dist/operations/supplier-invoice-payments.d.ts +20 -0
- package/dist/operations/supplier-invoice-payments.d.ts.map +1 -0
- package/dist/operations/supplier-invoice-payments.js +34 -0
- package/dist/operations/supplier-invoice-payments.js.map +1 -0
- package/dist/operations/taxreductions.d.ts +19 -0
- package/dist/operations/taxreductions.d.ts.map +1 -0
- package/dist/operations/taxreductions.js +28 -0
- package/dist/operations/taxreductions.js.map +1 -0
- package/dist/operations/vouchers.d.ts +15 -0
- package/dist/operations/vouchers.d.ts.map +1 -1
- package/dist/operations/vouchers.js +97 -0
- package/dist/operations/vouchers.js.map +1 -1
- package/dist/profile-name.d.ts +9 -0
- package/dist/profile-name.d.ts.map +1 -0
- package/dist/profile-name.js +58 -0
- package/dist/profile-name.js.map +1 -0
- package/dist/profiles.d.ts +55 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +210 -0
- package/dist/profiles.js.map +1 -0
- package/dist/tools/analytics.d.ts +3 -0
- package/dist/tools/analytics.d.ts.map +1 -0
- package/dist/tools/analytics.js +52 -0
- package/dist/tools/analytics.js.map +1 -0
- package/dist/tools/bookkeeping.d.ts.map +1 -1
- package/dist/tools/bookkeeping.js +38 -2
- package/dist/tools/bookkeeping.js.map +1 -1
- package/dist/tools/contracts.d.ts +3 -0
- package/dist/tools/contracts.d.ts.map +1 -0
- package/dist/tools/contracts.js +136 -0
- package/dist/tools/contracts.js.map +1 -0
- package/dist/tools/costcenters.d.ts +3 -0
- package/dist/tools/costcenters.d.ts.map +1 -0
- package/dist/tools/costcenters.js +79 -0
- package/dist/tools/costcenters.js.map +1 -0
- package/dist/tools/financial-years.d.ts +3 -0
- package/dist/tools/financial-years.d.ts.map +1 -0
- package/dist/tools/financial-years.js +33 -0
- package/dist/tools/financial-years.js.map +1 -0
- package/dist/tools/invoice-payments.d.ts +3 -0
- package/dist/tools/invoice-payments.d.ts.map +1 -0
- package/dist/tools/invoice-payments.js +60 -0
- package/dist/tools/invoice-payments.js.map +1 -0
- package/dist/tools/offers.d.ts +3 -0
- package/dist/tools/offers.d.ts.map +1 -0
- package/dist/tools/offers.js +130 -0
- package/dist/tools/offers.js.map +1 -0
- package/dist/tools/orders.d.ts +3 -0
- package/dist/tools/orders.d.ts.map +1 -0
- package/dist/tools/orders.js +114 -0
- package/dist/tools/orders.js.map +1 -0
- package/dist/tools/pricelists.d.ts +3 -0
- package/dist/tools/pricelists.d.ts.map +1 -0
- package/dist/tools/pricelists.js +104 -0
- package/dist/tools/pricelists.js.map +1 -0
- package/dist/tools/projects.d.ts +3 -0
- package/dist/tools/projects.d.ts.map +1 -0
- package/dist/tools/projects.js +80 -0
- package/dist/tools/projects.js.map +1 -0
- package/dist/tools/status.d.ts.map +1 -1
- package/dist/tools/status.js +6 -1
- package/dist/tools/status.js.map +1 -1
- package/dist/tools/supplier-invoice-payments.d.ts +3 -0
- package/dist/tools/supplier-invoice-payments.d.ts.map +1 -0
- package/dist/tools/supplier-invoice-payments.js +60 -0
- package/dist/tools/supplier-invoice-payments.js.map +1 -0
- package/dist/tools/taxreductions.d.ts +3 -0
- package/dist/tools/taxreductions.d.ts.map +1 -0
- package/dist/tools/taxreductions.js +51 -0
- package/dist/tools/taxreductions.js.map +1 -0
- package/dist/views.d.ts +28 -0
- package/dist/views.d.ts.map +1 -1
- package/dist/views.js +234 -0
- package/dist/views.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,19 +2,130 @@
|
|
|
2
2
|
import { Command, Option } from 'commander';
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { createInterface } from 'node:readline/promises';
|
|
5
|
-
import { isJsonMode, outputList, outputDetail, outputConfirmation, formatTaxReport, formatFinancialReport, } from './formatter.js';
|
|
6
|
-
import {
|
|
5
|
+
import { isJsonMode, outputList, outputDetail, outputConfirmation, formatTaxReport, formatFinancialReport, errorEnvelope, } from './formatter.js';
|
|
6
|
+
import { readActivePointer, readActivePointerOutcome, writeActivePointer, deleteActivePointer, readProfileIndex, resolveProfile, } from './profiles.js';
|
|
7
|
+
import { setResolvedProfile } from './auth.js';
|
|
8
|
+
import { applyPeriod } from './date-periods.js';
|
|
9
|
+
import { DEFAULT_PROFILE, InvalidProfileNameError } from './profile-name.js';
|
|
10
|
+
import { invoiceListColumns, invoiceDetailColumns, invoiceConfirmColumns, customerListColumns, customerDetailColumns, voucherListColumns, voucherDetailColumns, voucherRowColumns, accountListColumns, companyDetailColumns, articleListColumns, articleDetailColumns, supplierListColumns, supplierDetailColumns, supplierInvoiceListColumns, supplierInvoiceDetailColumns, supplierInvoiceConfirmColumns, invoicePaymentListColumns, invoicePaymentDetailColumns, supplierInvoicePaymentListColumns, supplierInvoicePaymentDetailColumns, offerListColumns, offerDetailColumns, orderListColumns, orderDetailColumns, projectListColumns, projectDetailColumns, costCenterListColumns, costCenterDetailColumns, taxReductionListColumns, taxReductionDetailColumns, priceListListColumns, priceListDetailColumns, priceListColumns, priceDetailColumns, financialYearListColumns, financialYearDetailColumns, lockedPeriodDetailColumns, contractListColumns, contractDetailColumns, topCustomerColumns, monthlyRevenueColumns, voucherAttachmentColumns, } from './views.js';
|
|
7
11
|
const program = new Command();
|
|
8
12
|
program
|
|
9
13
|
.name('noxctl')
|
|
10
14
|
.description('CLI and MCP server for Fortnox accounting')
|
|
11
|
-
.version('0.
|
|
12
|
-
.addOption(new Option('-o, --output <format>', 'Output format')
|
|
15
|
+
.version('0.3.0')
|
|
16
|
+
.addOption(new Option('-o, --output <format>', 'Output format (default: table on TTY, json when piped)')
|
|
13
17
|
.choices(['json', 'table'])
|
|
14
|
-
.default(undefined))
|
|
18
|
+
.default(undefined))
|
|
19
|
+
.option('--profile <name>', 'Profile to operate on (overrides NOXCTL_PROFILE and active pointer)');
|
|
15
20
|
function json() {
|
|
16
21
|
return isJsonMode(program.opts());
|
|
17
22
|
}
|
|
23
|
+
// Suppress Commander's own plain-text usage errors (unknown command, missing
|
|
24
|
+
// required option, ...) when in JSON mode, so the top-level catch can emit a
|
|
25
|
+
// structured error envelope instead of leaving non-JSON on stderr. In table
|
|
26
|
+
// mode, keep Commander's friendly usage message.
|
|
27
|
+
//
|
|
28
|
+
// Both configureOutput and exitOverride must be set HERE, before the command
|
|
29
|
+
// tree is built: Commander only copies these settings into subcommands created
|
|
30
|
+
// *after* they're configured on the parent. Set at the bottom, a subcommand's
|
|
31
|
+
// parse error would call process.exit() directly and bypass the catch below
|
|
32
|
+
// (leaving JSON-mode failures with no envelope).
|
|
33
|
+
program.configureOutput({
|
|
34
|
+
outputError: (str, write) => {
|
|
35
|
+
if (!json())
|
|
36
|
+
write(str);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
program.exitOverride();
|
|
40
|
+
// Emit a fatal error honoring -o json mode (structured envelope to stderr) vs
|
|
41
|
+
// plain text, then exit. Validation failures inside command actions must go
|
|
42
|
+
// through this so they don't bypass the JSON error contract the way a bare
|
|
43
|
+
// console.error would. The top-level catch handles thrown errors the same way;
|
|
44
|
+
// this is for the cases where we want a specific non-1 exit code.
|
|
45
|
+
function fail(message, exitCode = 1) {
|
|
46
|
+
if (json()) {
|
|
47
|
+
console.error(JSON.stringify(errorEnvelope(new Error(message)), null, 2));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
console.error(message);
|
|
51
|
+
}
|
|
52
|
+
process.exit(exitCode);
|
|
53
|
+
}
|
|
54
|
+
function fromToParams(opts) {
|
|
55
|
+
const { from, to } = applyPeriod(opts);
|
|
56
|
+
return { fromDate: from, toDate: to };
|
|
57
|
+
}
|
|
58
|
+
let resolvedProfileInfo = { name: DEFAULT_PROFILE, source: 'default' };
|
|
59
|
+
export function getResolvedProfileInfo() {
|
|
60
|
+
return resolvedProfileInfo;
|
|
61
|
+
}
|
|
62
|
+
// Commands that don't need (and shouldn't require) a resolved profile.
|
|
63
|
+
const PROFILE_RESOLUTION_SKIP = new Set(['help', 'completion']);
|
|
64
|
+
program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
65
|
+
const name = actionCommand.name();
|
|
66
|
+
if (PROFILE_RESOLUTION_SKIP.has(name))
|
|
67
|
+
return;
|
|
68
|
+
const flag = program.opts().profile ?? undefined;
|
|
69
|
+
const env = process.env['NOXCTL_PROFILE'] ?? undefined;
|
|
70
|
+
// Typed pointer resolution so we can distinguish "no pointer" (silent
|
|
71
|
+
// default) from "pointer unreadable/corrupt" (must surface). 2s bound
|
|
72
|
+
// catches wedged filesystems; the AbortSignal cancels the underlying read.
|
|
73
|
+
const outcome = await readActivePointerOutcome({ timeoutMs: 2000 });
|
|
74
|
+
let pointer = null;
|
|
75
|
+
if (outcome.kind === 'valid') {
|
|
76
|
+
pointer = outcome.name;
|
|
77
|
+
}
|
|
78
|
+
else if (outcome.kind !== 'missing') {
|
|
79
|
+
const desc = describePointerFault(outcome);
|
|
80
|
+
// Only fail closed when the pointer is the winning source (no flag/env)
|
|
81
|
+
// AND we're about to start the MCP server. For any other command, warn
|
|
82
|
+
// loudly but continue so `doctor` / `profile use` can repair the state.
|
|
83
|
+
const wouldRelyOnPointer = !flag && !env;
|
|
84
|
+
if (wouldRelyOnPointer && name === 'serve') {
|
|
85
|
+
fail(`Active profile pointer ${desc}. Refusing to start MCP server with ambiguous profile. Run \`noxctl doctor\` or set NOXCTL_PROFILE explicitly.`, 2);
|
|
86
|
+
}
|
|
87
|
+
process.stderr.write(`[warning: active-profile pointer ${desc}; ignoring]\n`);
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
resolvedProfileInfo = resolveProfile({ flag, env, pointer });
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
if (err instanceof InvalidProfileNameError) {
|
|
94
|
+
fail(err.message, 2);
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
setResolvedProfile(resolvedProfileInfo.name);
|
|
99
|
+
// Banner ownership: MCP `serve` prints its own banner in bindStartupProfile
|
|
100
|
+
// so host logs (Claude Desktop) always see it. Suppress here to avoid a
|
|
101
|
+
// duplicate on TTY invocations like `noxctl --profile X serve`.
|
|
102
|
+
if (resolvedProfileInfo.name.toLowerCase() !== DEFAULT_PROFILE &&
|
|
103
|
+
process.stderr.isTTY &&
|
|
104
|
+
name !== 'current' &&
|
|
105
|
+
name !== 'serve') {
|
|
106
|
+
process.stderr.write(`[profile: ${resolvedProfileInfo.name}]\n`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
function describePointerFault(outcome) {
|
|
110
|
+
switch (outcome.kind) {
|
|
111
|
+
case 'invalid-content':
|
|
112
|
+
return `contains an invalid profile name: "${outcome.raw}"`;
|
|
113
|
+
case 'read-error':
|
|
114
|
+
return `could not be read (${outcome.error.message})`;
|
|
115
|
+
case 'timeout':
|
|
116
|
+
return 'read timed out';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function fetchCompanyHint() {
|
|
120
|
+
try {
|
|
121
|
+
const { loadCredentials } = await import('./auth.js');
|
|
122
|
+
const creds = await loadCredentials();
|
|
123
|
+
return creds?.company_name;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
18
129
|
async function confirmMutation(action, opts, payload) {
|
|
19
130
|
if (opts.dryRun) {
|
|
20
131
|
console.log(`Dry run: ${action}. No request was sent to Fortnox.`);
|
|
@@ -29,12 +140,41 @@ async function confirmMutation(action, opts, payload) {
|
|
|
29
140
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
30
141
|
throw new Error(`Confirmation required to ${action}. Re-run with --yes, or --dry-run first.`);
|
|
31
142
|
}
|
|
143
|
+
const company = await fetchCompanyHint();
|
|
144
|
+
const suffix = company ? ` (${company})` : '';
|
|
145
|
+
// Show what will actually be sent so the user can verify before confirming.
|
|
146
|
+
if (payload !== undefined) {
|
|
147
|
+
console.log('Request payload:');
|
|
148
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
149
|
+
console.log();
|
|
150
|
+
}
|
|
32
151
|
const rl = createInterface({
|
|
33
152
|
input: process.stdin,
|
|
34
153
|
output: process.stdout,
|
|
35
154
|
});
|
|
36
155
|
try {
|
|
37
|
-
const answer = await rl.question(`${action}. Continue
|
|
156
|
+
const answer = await rl.question(`${action}. Continue?${suffix} [y/N] `);
|
|
157
|
+
return ['y', 'yes'].includes(answer.trim().toLowerCase());
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
rl.close();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function requireDarwin() {
|
|
164
|
+
if (process.platform !== 'darwin') {
|
|
165
|
+
fail('The dedicated YubiKey-locked keychain is a macOS-only feature.', 2);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Confirmation for local, irreversible operations (not Fortnox mutations).
|
|
169
|
+
async function localConfirm(question, yes) {
|
|
170
|
+
if (yes)
|
|
171
|
+
return true;
|
|
172
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
173
|
+
throw new Error('Confirmation required. Re-run with --yes.');
|
|
174
|
+
}
|
|
175
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
176
|
+
try {
|
|
177
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
38
178
|
return ['y', 'yes'].includes(answer.trim().toLowerCase());
|
|
39
179
|
}
|
|
40
180
|
finally {
|
|
@@ -45,10 +185,28 @@ async function confirmMutation(action, opts, payload) {
|
|
|
45
185
|
program
|
|
46
186
|
.command('init')
|
|
47
187
|
.description('Interactive setup wizard — recommended onboarding path')
|
|
48
|
-
.
|
|
188
|
+
.option('--profile <name>', 'Profile to create/re-auth (defaults to resolved profile or "default")')
|
|
189
|
+
.action(async (initOpts) => {
|
|
49
190
|
const { loadCredentials, runOAuthSetup } = await import('./auth.js');
|
|
191
|
+
const { validateProfileName } = await import('./profile-name.js');
|
|
192
|
+
let targetProfile;
|
|
193
|
+
try {
|
|
194
|
+
targetProfile = validateProfileName(initOpts.profile ?? resolvedProfileInfo.name);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
198
|
+
process.exit(2);
|
|
199
|
+
}
|
|
200
|
+
// If `init --profile <name>` names a different profile than the preAction
|
|
201
|
+
// hook resolved, rebind the in-process resolved profile so downstream work
|
|
202
|
+
// (verification via getCompanyInfo, runOAuthSetup's internal saveCredentials
|
|
203
|
+
// call chain) targets the profile being initialized — not a stale pointer.
|
|
204
|
+
if (targetProfile.toLowerCase() !== resolvedProfileInfo.name.toLowerCase()) {
|
|
205
|
+
setResolvedProfile(targetProfile);
|
|
206
|
+
resolvedProfileInfo = { name: targetProfile, source: 'flag' };
|
|
207
|
+
}
|
|
50
208
|
// Step 1: Check if already configured
|
|
51
|
-
const existing = await loadCredentials();
|
|
209
|
+
const existing = await loadCredentials(targetProfile);
|
|
52
210
|
if (existing) {
|
|
53
211
|
console.log('Existing credentials found.');
|
|
54
212
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
@@ -181,7 +339,20 @@ program
|
|
|
181
339
|
}
|
|
182
340
|
}
|
|
183
341
|
// Step 6: Run OAuth flow
|
|
184
|
-
await runOAuthSetup({ clientId, clientSecret, serviceAccount });
|
|
342
|
+
await runOAuthSetup({ clientId, clientSecret, serviceAccount }, targetProfile);
|
|
343
|
+
// Step 6b: Set the active pointer if this is the first profile or no pointer exists.
|
|
344
|
+
try {
|
|
345
|
+
const idx = await readProfileIndex();
|
|
346
|
+
const existingPointer = await readActivePointer();
|
|
347
|
+
const firstProfile = idx.profiles.length <= 1;
|
|
348
|
+
if (firstProfile || !existingPointer) {
|
|
349
|
+
await writeActivePointer(targetProfile);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
354
|
+
console.warn(`Warning: could not update active profile pointer: ${msg}`);
|
|
355
|
+
}
|
|
185
356
|
// Step 7: Verify by fetching company info
|
|
186
357
|
try {
|
|
187
358
|
const { getCompanyInfo } = await import('./operations/company.js');
|
|
@@ -267,28 +438,139 @@ program
|
|
|
267
438
|
// --- logout ---
|
|
268
439
|
program
|
|
269
440
|
.command('logout')
|
|
270
|
-
.description('Remove stored Fortnox credentials')
|
|
441
|
+
.description('Remove stored Fortnox credentials for the resolved profile')
|
|
271
442
|
.option('-y, --yes', 'Skip confirmation prompt')
|
|
443
|
+
.option('--all', 'Remove credentials for every profile (and the legacy slot)')
|
|
272
444
|
.action(async (opts) => {
|
|
273
|
-
const { loadCredentials } = await import('./auth.js');
|
|
274
445
|
const { deleteCredentialBlob } = await import('./credentials-store.js');
|
|
275
|
-
const
|
|
446
|
+
const { removeProfile } = await import('./profiles.js');
|
|
447
|
+
if (opts.all) {
|
|
448
|
+
if (!(await confirmMutation('Remove stored Fortnox credentials for ALL profiles', opts))) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const idx = await readProfileIndex();
|
|
452
|
+
const names = new Set(idx.profiles.map((p) => p.name));
|
|
453
|
+
names.add(DEFAULT_PROFILE);
|
|
454
|
+
let removedAny = false;
|
|
455
|
+
for (const name of names) {
|
|
456
|
+
const deleted = await deleteCredentialBlob(name);
|
|
457
|
+
if (deleted)
|
|
458
|
+
removedAny = true;
|
|
459
|
+
try {
|
|
460
|
+
await removeProfile(name);
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
// best-effort — index cleanup must not abort logout
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
await deleteActivePointer();
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// ignore
|
|
471
|
+
}
|
|
472
|
+
if (removedAny) {
|
|
473
|
+
console.log('Removed credentials for all profiles.');
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
console.log('No credentials found to remove.');
|
|
477
|
+
}
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const target = resolvedProfileInfo.name;
|
|
481
|
+
const { loadCredentials } = await import('./auth.js');
|
|
482
|
+
const existing = await loadCredentials(target);
|
|
276
483
|
if (!existing) {
|
|
277
|
-
console.log(
|
|
484
|
+
console.log(`No credentials found for profile "${target}". Nothing to remove.`);
|
|
278
485
|
return;
|
|
279
486
|
}
|
|
280
|
-
if (!(await confirmMutation(
|
|
487
|
+
if (!(await confirmMutation(`Remove stored credentials for profile "${target}"`, opts))) {
|
|
281
488
|
return;
|
|
282
489
|
}
|
|
283
|
-
const deleted = await deleteCredentialBlob();
|
|
490
|
+
const deleted = await deleteCredentialBlob(target);
|
|
491
|
+
try {
|
|
492
|
+
await removeProfile(target);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
// best-effort
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
const pointer = await readActivePointer();
|
|
499
|
+
if (pointer && pointer.toLowerCase() === target.toLowerCase()) {
|
|
500
|
+
await deleteActivePointer();
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// ignore
|
|
505
|
+
}
|
|
284
506
|
if (deleted) {
|
|
285
|
-
console.log(
|
|
507
|
+
console.log(`Credentials for profile "${target}" removed.`);
|
|
286
508
|
}
|
|
287
509
|
else {
|
|
288
510
|
console.log('Could not remove credentials from the system keychain.');
|
|
289
511
|
console.log('They may have already been removed, or you may need to remove them manually.');
|
|
290
512
|
}
|
|
291
513
|
});
|
|
514
|
+
// --- profile ---
|
|
515
|
+
const profile = program.command('profile').description('Manage noxctl profiles');
|
|
516
|
+
profile
|
|
517
|
+
.command('use <name>')
|
|
518
|
+
.description('Set the active profile (writes ~/.fortnox-mcp/active-profile)')
|
|
519
|
+
.action(async (name) => {
|
|
520
|
+
const { validateProfileName } = await import('./profile-name.js');
|
|
521
|
+
let validated;
|
|
522
|
+
try {
|
|
523
|
+
validated = validateProfileName(name);
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
527
|
+
process.exit(2);
|
|
528
|
+
}
|
|
529
|
+
const { loadCredentials } = await import('./auth.js');
|
|
530
|
+
const creds = await loadCredentials(validated);
|
|
531
|
+
if (!creds) {
|
|
532
|
+
console.error(`No credentials found for profile "${validated}". Run \`noxctl init --profile ${validated}\` first.`);
|
|
533
|
+
process.exit(1);
|
|
534
|
+
}
|
|
535
|
+
await writeActivePointer(validated);
|
|
536
|
+
if (json()) {
|
|
537
|
+
console.log(JSON.stringify({ name: validated, source: 'pointer' }));
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
console.log(`Active profile set to "${validated}".`);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
profile
|
|
544
|
+
.command('current')
|
|
545
|
+
.description('Show the currently resolved profile and where it came from')
|
|
546
|
+
.action(() => {
|
|
547
|
+
if (json()) {
|
|
548
|
+
console.log(JSON.stringify(resolvedProfileInfo));
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
console.log(`${resolvedProfileInfo.name} (source: ${resolvedProfileInfo.source})`);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
profile
|
|
555
|
+
.command('list')
|
|
556
|
+
.description('List known profiles from the index')
|
|
557
|
+
.action(async () => {
|
|
558
|
+
const idx = await readProfileIndex();
|
|
559
|
+
if (json()) {
|
|
560
|
+
console.log(JSON.stringify(idx.profiles, null, 2));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (idx.profiles.length === 0) {
|
|
564
|
+
console.log('No profiles registered. Run `noxctl init` to create one.');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
for (const p of idx.profiles) {
|
|
568
|
+
const marker = p.name.toLowerCase() === resolvedProfileInfo.name.toLowerCase() ? '*' : ' ';
|
|
569
|
+
const company = p.company_name ? ` — ${p.company_name}` : '';
|
|
570
|
+
const tenant = p.tenant_id ? ` [tenant ${p.tenant_id}]` : '';
|
|
571
|
+
console.log(`${marker} ${p.name}${company}${tenant}`);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
292
574
|
// --- doctor ---
|
|
293
575
|
program
|
|
294
576
|
.command('doctor')
|
|
@@ -320,14 +602,81 @@ program
|
|
|
320
602
|
? 'Windows DPAPI'
|
|
321
603
|
: 'Linux Secret Service';
|
|
322
604
|
pass('Credential store', storeBackend);
|
|
605
|
+
// 2a. Dedicated YubiKey-locked keychain (macOS only)
|
|
606
|
+
if (process.platform === 'darwin') {
|
|
607
|
+
const kt = await import('./keychain-target.js');
|
|
608
|
+
const activePath = kt.activeKeychainPath();
|
|
609
|
+
if (activePath) {
|
|
610
|
+
const state = kt.keychainLockState(activePath);
|
|
611
|
+
if (state === 'locked') {
|
|
612
|
+
fail('Dedicated keychain', 'locked — run `noxctl keychain unlock` (tap your YubiKey)');
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
pass('Dedicated keychain', `active, ${state} (${activePath})`);
|
|
616
|
+
}
|
|
617
|
+
pass('ykman', kt.ykmanAvailable() ? 'installed' : 'not installed (brew install ykman)');
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
pass('Dedicated keychain', 'not enabled (using login keychain)');
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// 2b. Profile resolution
|
|
624
|
+
pass('Profile', `${resolvedProfileInfo.name} (source: ${resolvedProfileInfo.source})`);
|
|
625
|
+
// 2c. Active pointer health — surface a corrupt pointer file even though
|
|
626
|
+
// readActivePointer() degrades silently.
|
|
627
|
+
try {
|
|
628
|
+
const { paths } = await import('./profiles.js');
|
|
629
|
+
const fsp = await import('node:fs/promises');
|
|
630
|
+
const raw = await fsp.readFile(paths.activePointerFile, 'utf-8').catch(() => null);
|
|
631
|
+
if (raw !== null) {
|
|
632
|
+
const trimmed = raw.trim();
|
|
633
|
+
if (trimmed.length > 0) {
|
|
634
|
+
const { validateProfileName } = await import('./profile-name.js');
|
|
635
|
+
try {
|
|
636
|
+
validateProfileName(trimmed);
|
|
637
|
+
const idx = await readProfileIndex();
|
|
638
|
+
const known = idx.profiles.some((p) => p.name.toLowerCase() === trimmed.toLowerCase());
|
|
639
|
+
if (known) {
|
|
640
|
+
pass('Active pointer', trimmed);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
fail('Active pointer', `points to unknown profile "${trimmed}" — run \`noxctl profile use <name>\` to fix`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
fail('Active pointer', 'contains an invalid name — delete to reset');
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
// best-effort diagnostic — don't block doctor on filesystem issues
|
|
654
|
+
}
|
|
323
655
|
// 3. Credentials exist
|
|
324
|
-
|
|
656
|
+
let creds;
|
|
657
|
+
try {
|
|
658
|
+
creds = await loadCredentials();
|
|
659
|
+
}
|
|
660
|
+
catch (err) {
|
|
661
|
+
const { KeychainLockedError } = await import('./keychain-target.js');
|
|
662
|
+
if (err instanceof KeychainLockedError) {
|
|
663
|
+
fail('Credentials', err.message);
|
|
664
|
+
console.log(`\n${ok ? 'All checks passed.' : 'Some checks failed.'}`);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
throw err;
|
|
668
|
+
}
|
|
325
669
|
if (!creds) {
|
|
326
|
-
fail('Credentials',
|
|
670
|
+
fail('Credentials', `not found for profile "${resolvedProfileInfo.name}" — run \`noxctl init${resolvedProfileInfo.name === DEFAULT_PROFILE
|
|
671
|
+
? ''
|
|
672
|
+
: ` --profile ${resolvedProfileInfo.name}`}\` to set up`);
|
|
327
673
|
console.log(`\n${ok ? 'All checks passed.' : 'Some checks failed.'}`);
|
|
328
674
|
return;
|
|
329
675
|
}
|
|
330
676
|
pass('Credentials', 'found');
|
|
677
|
+
if (creds.company_name) {
|
|
678
|
+
pass('Company (cached)', creds.company_name);
|
|
679
|
+
}
|
|
331
680
|
// 4. Client ID present
|
|
332
681
|
if (creds.client_id) {
|
|
333
682
|
pass('Client ID', `${creds.client_id.slice(0, 8)}...`);
|
|
@@ -372,11 +721,14 @@ program
|
|
|
372
721
|
article: 'articles?limit=1',
|
|
373
722
|
customer: 'customers?limit=1',
|
|
374
723
|
invoice: 'invoices?limit=1',
|
|
724
|
+
payment: 'invoicepayments?limit=1',
|
|
375
725
|
supplier: 'suppliers?limit=1',
|
|
376
726
|
supplierinvoice: 'supplierinvoices?limit=1',
|
|
377
727
|
bookkeeping: 'vouchers?limit=1',
|
|
378
728
|
companyinformation: 'companyinformation',
|
|
379
729
|
settings: 'settings/company',
|
|
730
|
+
inbox: 'inbox',
|
|
731
|
+
connectfile: 'voucherfileconnections?limit=1',
|
|
380
732
|
};
|
|
381
733
|
const required = SCOPES.split(' ');
|
|
382
734
|
const missing = [];
|
|
@@ -388,10 +740,13 @@ program
|
|
|
388
740
|
await fortnoxRequest(endpoint);
|
|
389
741
|
}
|
|
390
742
|
catch (err) {
|
|
391
|
-
|
|
743
|
+
// A missing scope shows up as a 403, OR (for some endpoints, e.g. the
|
|
744
|
+
// archive/inbox family) a 400 whose message names the scope. Treat both
|
|
745
|
+
// as "not authorized"; other errors (500, etc.) aren't scope problems.
|
|
746
|
+
if (err instanceof FortnoxApiError &&
|
|
747
|
+
(err.statusCode === 403 || /scope/i.test(err.fortnoxMessage ?? ''))) {
|
|
392
748
|
missing.push(scope);
|
|
393
749
|
}
|
|
394
|
-
// Non-403 errors (e.g. 500) are not scope problems — ignore here
|
|
395
750
|
}
|
|
396
751
|
}
|
|
397
752
|
if (missing.length === 0) {
|
|
@@ -402,13 +757,252 @@ program
|
|
|
402
757
|
}
|
|
403
758
|
console.log(`\n${ok ? 'All checks passed.' : 'Some checks failed.'}`);
|
|
404
759
|
});
|
|
760
|
+
// --- keychain (dedicated YubiKey-locked credential store, macOS only) ---
|
|
761
|
+
const keychain = program
|
|
762
|
+
.command('keychain')
|
|
763
|
+
.description('Manage the dedicated YubiKey-locked credential keychain (macOS)');
|
|
764
|
+
keychain
|
|
765
|
+
.command('status')
|
|
766
|
+
.description('Show dedicated-keychain mode, lock state, and YubiKey availability')
|
|
767
|
+
.action(async () => {
|
|
768
|
+
const kt = await import('./keychain-target.js');
|
|
769
|
+
const onDarwin = process.platform === 'darwin';
|
|
770
|
+
console.log('Keychain status\n');
|
|
771
|
+
console.log(` Platform ${process.platform}${onDarwin ? '' : ' (dedicated keychain is macOS-only)'}`);
|
|
772
|
+
if (!onDarwin)
|
|
773
|
+
return;
|
|
774
|
+
const activePath = kt.activeKeychainPath();
|
|
775
|
+
const kcPath = activePath ?? kt.dedicatedKeychainPath();
|
|
776
|
+
const lockState = kt.keychainLockState(kcPath);
|
|
777
|
+
console.log(` Dedicated mode ${activePath ? 'active' : 'inactive (using login keychain)'}`);
|
|
778
|
+
console.log(` Keychain file ${kcPath} (${lockState})`);
|
|
779
|
+
console.log(` Challenge file ${kt.readChallenge() ? kt.challengeFilePath() : 'not present'}`);
|
|
780
|
+
console.log(` ykman installed ${kt.ykmanAvailable() ? 'yes' : 'no (brew install ykman)'}`);
|
|
781
|
+
console.log(` YubiKey present ${kt.yubikeyPresent() ? 'yes' : 'no'}`);
|
|
782
|
+
const enrolledSerial = kt.readEnrolledSerial();
|
|
783
|
+
console.log(` Enrolled serial ${enrolledSerial ?? 'not recorded'}`);
|
|
784
|
+
if (enrolledSerial) {
|
|
785
|
+
const mismatch = kt.diagnoseSerialMismatch(enrolledSerial, kt.listYubikeySerials());
|
|
786
|
+
if (mismatch)
|
|
787
|
+
console.log(`\n ${mismatch}`);
|
|
788
|
+
}
|
|
789
|
+
if (activePath && lockState === 'locked') {
|
|
790
|
+
console.log('\n Locked — run `noxctl keychain unlock` (tap your YubiKey).');
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
keychain
|
|
794
|
+
.command('unlock')
|
|
795
|
+
.description('Unlock the dedicated keychain for this session (tap your YubiKey)')
|
|
796
|
+
.action(async () => {
|
|
797
|
+
requireDarwin();
|
|
798
|
+
const kt = await import('./keychain-target.js');
|
|
799
|
+
const kcPath = kt.activeKeychainPath() ?? kt.dedicatedKeychainPath();
|
|
800
|
+
const state = kt.keychainLockState(kcPath);
|
|
801
|
+
if (state === 'missing') {
|
|
802
|
+
console.error('No dedicated keychain found. Run `noxctl keychain init` first.');
|
|
803
|
+
process.exit(1);
|
|
804
|
+
}
|
|
805
|
+
if (state === 'unlocked') {
|
|
806
|
+
console.log('Keychain is already unlocked.');
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const challenge = kt.readChallenge();
|
|
810
|
+
if (!challenge) {
|
|
811
|
+
console.error('Challenge file missing — cannot derive the unlock password. Re-run `noxctl keychain init`.');
|
|
812
|
+
process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
// Preflight: a wrong/absent key is diagnosable from serials alone, before
|
|
815
|
+
// burning a tap on a challenge that can only fail.
|
|
816
|
+
const mismatch = kt.diagnoseSerialMismatch(kt.readEnrolledSerial(), kt.listYubikeySerials());
|
|
817
|
+
if (mismatch) {
|
|
818
|
+
console.error(mismatch);
|
|
819
|
+
process.exit(1);
|
|
820
|
+
}
|
|
821
|
+
console.log('Tap your YubiKey when it blinks...');
|
|
822
|
+
let password;
|
|
823
|
+
try {
|
|
824
|
+
password = kt.computeChallengeResponse(challenge);
|
|
825
|
+
}
|
|
826
|
+
catch (err) {
|
|
827
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
828
|
+
process.exit(1);
|
|
829
|
+
}
|
|
830
|
+
try {
|
|
831
|
+
kt.unlockDedicatedKeychain(kcPath, password);
|
|
832
|
+
}
|
|
833
|
+
catch (err) {
|
|
834
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
835
|
+
process.exit(1);
|
|
836
|
+
}
|
|
837
|
+
console.log('Keychain unlocked — open until your Mac sleeps.');
|
|
838
|
+
});
|
|
839
|
+
keychain
|
|
840
|
+
.command('lock')
|
|
841
|
+
.description('Lock the dedicated keychain now')
|
|
842
|
+
.action(async () => {
|
|
843
|
+
requireDarwin();
|
|
844
|
+
const kt = await import('./keychain-target.js');
|
|
845
|
+
const kcPath = kt.activeKeychainPath() ?? kt.dedicatedKeychainPath();
|
|
846
|
+
if (kt.keychainLockState(kcPath) === 'missing') {
|
|
847
|
+
console.error('No dedicated keychain found.');
|
|
848
|
+
process.exit(1);
|
|
849
|
+
}
|
|
850
|
+
kt.lockKeychain(kcPath);
|
|
851
|
+
console.log('Keychain locked.');
|
|
852
|
+
});
|
|
853
|
+
keychain
|
|
854
|
+
.command('init')
|
|
855
|
+
.description('Create the YubiKey-locked keychain and copy in existing credentials')
|
|
856
|
+
.action(async () => {
|
|
857
|
+
requireDarwin();
|
|
858
|
+
const kt = await import('./keychain-target.js');
|
|
859
|
+
if (!kt.ykmanAvailable()) {
|
|
860
|
+
console.error('ykman not found — install it with `brew install ykman`, then re-run.');
|
|
861
|
+
process.exit(1);
|
|
862
|
+
}
|
|
863
|
+
if (!kt.yubikeyPresent()) {
|
|
864
|
+
console.error('No YubiKey detected — insert it and re-run.');
|
|
865
|
+
process.exit(1);
|
|
866
|
+
}
|
|
867
|
+
const kcPath = kt.dedicatedKeychainPath();
|
|
868
|
+
if (kt.readChallenge()) {
|
|
869
|
+
console.error('Already initialized. Use `noxctl keychain unlock` or `noxctl keychain status`.');
|
|
870
|
+
process.exit(1);
|
|
871
|
+
}
|
|
872
|
+
if (kt.keychainLockState(kcPath) !== 'missing') {
|
|
873
|
+
console.error(`A keychain already exists at ${kcPath} but no challenge file is present.\n` +
|
|
874
|
+
`Remove the stale keychain (\`security delete-keychain "${kcPath}"\`) and re-run.`);
|
|
875
|
+
process.exit(1);
|
|
876
|
+
}
|
|
877
|
+
console.log('This creates a dedicated, lock-on-sleep keychain whose password is derived');
|
|
878
|
+
console.log('from your YubiKey (OTP slot 2 challenge-response). You unlock it once per');
|
|
879
|
+
console.log('session with a tap. Existing credentials are copied in; the login-keychain');
|
|
880
|
+
console.log('originals are left in place (run `noxctl keychain seal` to remove them later).');
|
|
881
|
+
console.log('');
|
|
882
|
+
console.log('Slot 2 must already be programmed: `ykman otp chalresp --generate --touch 2`.');
|
|
883
|
+
console.log('');
|
|
884
|
+
// Collect existing credentials from the LOGIN keychain BEFORE activating
|
|
885
|
+
// dedicated mode (challenge file absent -> activeKeychainPath() is null).
|
|
886
|
+
const { readProfileIndex } = await import('./profiles.js');
|
|
887
|
+
const { loadCredentialBlob, saveCredentialBlob } = await import('./credentials-store.js');
|
|
888
|
+
const idx = await readProfileIndex();
|
|
889
|
+
const names = new Set([DEFAULT_PROFILE, ...idx.profiles.map((p) => p.name)]);
|
|
890
|
+
const blobs = [];
|
|
891
|
+
for (const name of names) {
|
|
892
|
+
const { blob } = await loadCredentialBlob(name);
|
|
893
|
+
if (blob)
|
|
894
|
+
blobs.push({ profile: name, blob });
|
|
895
|
+
}
|
|
896
|
+
console.log(`Found ${blobs.length} credential set(s) to copy: ${blobs.map((b) => b.profile).join(', ') || '(none)'}`);
|
|
897
|
+
console.log('');
|
|
898
|
+
// Derive the keychain password from the YubiKey (requires a tap). Done
|
|
899
|
+
// before creating the keychain so a missed tap leaves nothing behind.
|
|
900
|
+
const challenge = kt.generateChallenge();
|
|
901
|
+
console.log('Tap your YubiKey when it blinks...');
|
|
902
|
+
let password;
|
|
903
|
+
try {
|
|
904
|
+
password = kt.computeChallengeResponse(challenge);
|
|
905
|
+
}
|
|
906
|
+
catch (err) {
|
|
907
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
908
|
+
process.exit(1);
|
|
909
|
+
}
|
|
910
|
+
const created = kt.createDedicatedKeychain(kcPath, password);
|
|
911
|
+
if (created === 'exists') {
|
|
912
|
+
console.error(`Unexpected: keychain already exists at ${kcPath}. Aborting.`);
|
|
913
|
+
process.exit(1);
|
|
914
|
+
}
|
|
915
|
+
kt.setLockOnSleep(kcPath);
|
|
916
|
+
// Activate dedicated mode: now activeKeychainPath() resolves to kcPath and
|
|
917
|
+
// saveCredentialBlob targets the new (still-unlocked) keychain.
|
|
918
|
+
kt.writeChallenge(challenge);
|
|
919
|
+
// Remember which physical key answered the enrollment challenge so unlock
|
|
920
|
+
// can distinguish "wrong key inserted" from "missed tap" later.
|
|
921
|
+
const serials = kt.listYubikeySerials();
|
|
922
|
+
if (serials.length === 1) {
|
|
923
|
+
kt.writeEnrolledSerial(serials[0]);
|
|
924
|
+
console.log(`Enrolled against YubiKey serial ${serials[0]}.`);
|
|
925
|
+
}
|
|
926
|
+
else if (serials.length > 1) {
|
|
927
|
+
console.log(`Multiple YubiKeys present (${serials.join(', ')}) — not recording an enrolled serial.`);
|
|
928
|
+
}
|
|
929
|
+
let copied = 0;
|
|
930
|
+
for (const { profile, blob } of blobs) {
|
|
931
|
+
try {
|
|
932
|
+
await saveCredentialBlob(blob, profile);
|
|
933
|
+
copied++;
|
|
934
|
+
}
|
|
935
|
+
catch (err) {
|
|
936
|
+
console.error(` ! failed to copy "${profile}": ${err instanceof Error ? err.message : String(err)}`);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
console.log('');
|
|
940
|
+
console.log(`Done. Created ${kcPath}, copied ${copied}/${blobs.length} credential set(s).`);
|
|
941
|
+
console.log('The keychain is unlocked now and will lock when your Mac sleeps.');
|
|
942
|
+
console.log('Next session: `noxctl keychain unlock` (one tap).');
|
|
943
|
+
console.log('When you trust it: `noxctl keychain seal` to delete the login-keychain copies.');
|
|
944
|
+
});
|
|
945
|
+
keychain
|
|
946
|
+
.command('seal')
|
|
947
|
+
.description('Delete the login-keychain credential copies left by `init` (irreversible)')
|
|
948
|
+
.option('--yes', 'Skip the confirmation prompt')
|
|
949
|
+
.action(async (opts) => {
|
|
950
|
+
requireDarwin();
|
|
951
|
+
const kt = await import('./keychain-target.js');
|
|
952
|
+
const activePath = kt.activeKeychainPath();
|
|
953
|
+
if (!activePath) {
|
|
954
|
+
console.error('Dedicated keychain mode is not active — nothing to seal. Run `noxctl keychain init` first.');
|
|
955
|
+
process.exit(1);
|
|
956
|
+
}
|
|
957
|
+
if (kt.keychainLockState(activePath) === 'locked') {
|
|
958
|
+
console.error('Dedicated keychain is locked — run `noxctl keychain unlock` first so the copies can be verified before the originals are deleted.');
|
|
959
|
+
process.exit(1);
|
|
960
|
+
}
|
|
961
|
+
const { readProfileIndex } = await import('./profiles.js');
|
|
962
|
+
const { LEGACY_KEYCHAIN_ACCOUNT, keychainAccount } = await import('./profile-name.js');
|
|
963
|
+
const { loadCredentialBlob } = await import('./credentials-store.js');
|
|
964
|
+
const idx = await readProfileIndex();
|
|
965
|
+
const uniqueNames = [
|
|
966
|
+
...new Set([DEFAULT_PROFILE, ...idx.profiles.map((p) => p.name)].map((n) => n.toLowerCase())),
|
|
967
|
+
];
|
|
968
|
+
// Verify the dedicated keychain actually holds each profile's creds before
|
|
969
|
+
// deleting the login originals — never strand the user with no copy.
|
|
970
|
+
const verified = [];
|
|
971
|
+
for (const name of uniqueNames) {
|
|
972
|
+
const { blob } = await loadCredentialBlob(name);
|
|
973
|
+
if (blob)
|
|
974
|
+
verified.push(name);
|
|
975
|
+
}
|
|
976
|
+
if (verified.length === 0) {
|
|
977
|
+
console.error('The dedicated keychain holds no credentials — refusing to delete the login copies.');
|
|
978
|
+
process.exit(1);
|
|
979
|
+
}
|
|
980
|
+
console.log(`Verified ${verified.length} credential set(s) in the dedicated keychain: ${verified.join(', ')}`);
|
|
981
|
+
const confirmed = await localConfirm('Delete the login-keychain copies now? This cannot be undone.', opts.yes);
|
|
982
|
+
if (!confirmed) {
|
|
983
|
+
console.log('Aborted. Login-keychain copies left in place.');
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
// Accounts init may have written to the login keychain: the legacy
|
|
987
|
+
// "default" account plus profile:<name> for every known profile.
|
|
988
|
+
const accounts = new Set([LEGACY_KEYCHAIN_ACCOUNT]);
|
|
989
|
+
for (const name of uniqueNames)
|
|
990
|
+
accounts.add(keychainAccount(name));
|
|
991
|
+
let deleted = 0;
|
|
992
|
+
for (const account of accounts) {
|
|
993
|
+
if (kt.deleteLoginSecret(account))
|
|
994
|
+
deleted++;
|
|
995
|
+
}
|
|
996
|
+
console.log(`Deleted ${deleted} login-keychain entr${deleted === 1 ? 'y' : 'ies'}.`);
|
|
997
|
+
console.log('Credentials now live only in the YubiKey-locked keychain.');
|
|
998
|
+
});
|
|
405
999
|
// --- serve (default command) ---
|
|
406
1000
|
program
|
|
407
1001
|
.command('serve', { isDefault: true })
|
|
408
1002
|
.description('Start the MCP server (stdio transport)')
|
|
409
1003
|
.action(async () => {
|
|
410
1004
|
const { startMcpServer } = await import('./index.js');
|
|
411
|
-
await startMcpServer();
|
|
1005
|
+
await startMcpServer({ profile: resolvedProfileInfo.name });
|
|
412
1006
|
});
|
|
413
1007
|
// --- invoices ---
|
|
414
1008
|
const invoices = program.command('invoices').description('Invoice operations');
|
|
@@ -419,6 +1013,7 @@ invoices
|
|
|
419
1013
|
.option('--customer <number>', 'Filter by customer number')
|
|
420
1014
|
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
421
1015
|
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
1016
|
+
.option('--period <period>', 'Natural period (calendar-year): Q1, 2025-Q3, march/mars, this-quarter, last-quarter, this-month, last-month, ytd, this-year, last-year, or a bare year. Mutually exclusive with --from/--to.')
|
|
422
1017
|
.option('--page <number>', 'Page number', parseInt)
|
|
423
1018
|
.option('--limit <number>', 'Results per page', parseInt)
|
|
424
1019
|
.option('-a, --all', 'Fetch all pages')
|
|
@@ -427,8 +1022,7 @@ invoices
|
|
|
427
1022
|
const data = await listInvoices({
|
|
428
1023
|
filter: opts.filter,
|
|
429
1024
|
customerNumber: opts.customer,
|
|
430
|
-
|
|
431
|
-
toDate: opts.to,
|
|
1025
|
+
...fromToParams(opts),
|
|
432
1026
|
page: opts.page,
|
|
433
1027
|
limit: opts.limit,
|
|
434
1028
|
all: opts.all,
|
|
@@ -442,7 +1036,7 @@ invoices
|
|
|
442
1036
|
.action(async (documentNumber) => {
|
|
443
1037
|
const { getInvoice } = await import('./operations/invoices.js');
|
|
444
1038
|
const data = await getInvoice(documentNumber);
|
|
445
|
-
outputDetail(data, invoiceDetailColumns, json());
|
|
1039
|
+
outputDetail(data, invoiceDetailColumns, json(), 'Invoice');
|
|
446
1040
|
});
|
|
447
1041
|
invoices
|
|
448
1042
|
.command('create')
|
|
@@ -468,7 +1062,7 @@ Examples:
|
|
|
468
1062
|
return;
|
|
469
1063
|
}
|
|
470
1064
|
const data = await createInvoice(params);
|
|
471
|
-
outputDetail(data, invoiceDetailColumns, json());
|
|
1065
|
+
outputDetail(data, invoiceDetailColumns, json(), 'Invoice');
|
|
472
1066
|
});
|
|
473
1067
|
invoices
|
|
474
1068
|
.command('update <documentNumber>')
|
|
@@ -488,7 +1082,7 @@ Examples:
|
|
|
488
1082
|
return;
|
|
489
1083
|
}
|
|
490
1084
|
const data = await updateInvoice(documentNumber, fields);
|
|
491
|
-
outputDetail(data, invoiceDetailColumns, json());
|
|
1085
|
+
outputDetail(data, invoiceDetailColumns, json(), 'Invoice');
|
|
492
1086
|
});
|
|
493
1087
|
invoices
|
|
494
1088
|
.command('send <documentNumber>')
|
|
@@ -510,7 +1104,7 @@ invoices
|
|
|
510
1104
|
? { subject: opts.subject, body: opts.body, bcc: opts.bcc }
|
|
511
1105
|
: undefined;
|
|
512
1106
|
const data = await sendInvoice(documentNumber, opts.method, emailOptions);
|
|
513
|
-
outputConfirmation(`Invoice ${documentNumber} sent via ${opts.method}.`, json(), data, invoiceConfirmColumns);
|
|
1107
|
+
outputConfirmation(`Invoice ${documentNumber} sent via ${opts.method}.`, json(), data, invoiceConfirmColumns, 'Invoice');
|
|
514
1108
|
});
|
|
515
1109
|
invoices
|
|
516
1110
|
.command('bookkeep <documentNumber>')
|
|
@@ -523,7 +1117,7 @@ invoices
|
|
|
523
1117
|
return;
|
|
524
1118
|
}
|
|
525
1119
|
const data = await bookkeepInvoice(documentNumber);
|
|
526
|
-
outputConfirmation(`Invoice ${documentNumber} bookkeept.`, json(), data, invoiceConfirmColumns);
|
|
1120
|
+
outputConfirmation(`Invoice ${documentNumber} bookkeept.`, json(), data, invoiceConfirmColumns, 'Invoice');
|
|
527
1121
|
});
|
|
528
1122
|
invoices
|
|
529
1123
|
.command('credit <documentNumber>')
|
|
@@ -536,21 +1130,26 @@ invoices
|
|
|
536
1130
|
return;
|
|
537
1131
|
}
|
|
538
1132
|
const data = await creditInvoice(documentNumber);
|
|
539
|
-
outputConfirmation(`Invoice ${documentNumber} credited.`, json(), data, invoiceConfirmColumns);
|
|
1133
|
+
outputConfirmation(`Invoice ${documentNumber} credited.`, json(), data, invoiceConfirmColumns, 'Invoice');
|
|
540
1134
|
});
|
|
541
1135
|
// --- tax ---
|
|
542
1136
|
const tax = program.command('tax').description('Tax operations');
|
|
543
1137
|
tax
|
|
544
1138
|
.command('report')
|
|
545
1139
|
.description('Generate VAT tax report for a period')
|
|
546
|
-
.
|
|
547
|
-
.
|
|
1140
|
+
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
1141
|
+
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
1142
|
+
.option('--period <period>', 'Natural period (calendar-year): Q1, 2025-Q3, march/mars, this-quarter, last-quarter, this-month, last-month, ytd, this-year, last-year, or a bare year. Mutually exclusive with --from/--to.')
|
|
548
1143
|
.option('--year <number>', 'Financial year', parseInt)
|
|
549
1144
|
.action(async (opts) => {
|
|
550
1145
|
const { generateTaxReport } = await import('./operations/tax.js');
|
|
1146
|
+
const range = fromToParams(opts);
|
|
1147
|
+
if (!range.fromDate || !range.toDate) {
|
|
1148
|
+
fail('tax report requires a period: --from/--to or --period.', 2);
|
|
1149
|
+
}
|
|
551
1150
|
const data = await generateTaxReport({
|
|
552
|
-
fromDate:
|
|
553
|
-
toDate:
|
|
1151
|
+
fromDate: range.fromDate,
|
|
1152
|
+
toDate: range.toDate,
|
|
554
1153
|
financialYear: opts.year,
|
|
555
1154
|
});
|
|
556
1155
|
if (json()) {
|
|
@@ -571,12 +1170,12 @@ reports
|
|
|
571
1170
|
.option('--year <number>', 'Financial year', parseInt)
|
|
572
1171
|
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
573
1172
|
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
1173
|
+
.option('--period <period>', 'Natural period (calendar-year): Q1, 2025-Q3, march/mars, this-quarter, last-quarter, this-month, last-month, ytd, this-year, last-year, or a bare year. Mutually exclusive with --from/--to.')
|
|
574
1174
|
.action(async (opts) => {
|
|
575
1175
|
const { getIncomeStatement } = await import('./operations/financial-reports.js');
|
|
576
1176
|
const data = await getIncomeStatement({
|
|
577
1177
|
financialYear: opts.year,
|
|
578
|
-
|
|
579
|
-
toDate: opts.to,
|
|
1178
|
+
...fromToParams(opts),
|
|
580
1179
|
});
|
|
581
1180
|
if (json()) {
|
|
582
1181
|
console.log(JSON.stringify(data, null, 2));
|
|
@@ -648,7 +1247,7 @@ customers
|
|
|
648
1247
|
.action(async (customerNumber) => {
|
|
649
1248
|
const { getCustomer } = await import('./operations/customers.js');
|
|
650
1249
|
const data = await getCustomer(customerNumber);
|
|
651
|
-
outputDetail(data, customerDetailColumns, json());
|
|
1250
|
+
outputDetail(data, customerDetailColumns, json(), 'Customer');
|
|
652
1251
|
});
|
|
653
1252
|
customers
|
|
654
1253
|
.command('create')
|
|
@@ -673,7 +1272,7 @@ Examples:
|
|
|
673
1272
|
return;
|
|
674
1273
|
}
|
|
675
1274
|
const data = await createCustomer(params);
|
|
676
|
-
outputDetail(data, customerDetailColumns, json());
|
|
1275
|
+
outputDetail(data, customerDetailColumns, json(), 'Customer');
|
|
677
1276
|
});
|
|
678
1277
|
customers
|
|
679
1278
|
.command('update <customerNumber>')
|
|
@@ -692,7 +1291,7 @@ Examples:
|
|
|
692
1291
|
return;
|
|
693
1292
|
}
|
|
694
1293
|
const data = await updateCustomer(customerNumber, fields);
|
|
695
|
-
outputDetail(data, customerDetailColumns, json());
|
|
1294
|
+
outputDetail(data, customerDetailColumns, json(), 'Customer');
|
|
696
1295
|
});
|
|
697
1296
|
// --- articles ---
|
|
698
1297
|
const articles = program.command('articles').description('Article operations');
|
|
@@ -720,7 +1319,7 @@ articles
|
|
|
720
1319
|
.action(async (articleNumber) => {
|
|
721
1320
|
const { getArticle } = await import('./operations/articles.js');
|
|
722
1321
|
const data = await getArticle(articleNumber);
|
|
723
|
-
outputDetail(data, articleDetailColumns, json());
|
|
1322
|
+
outputDetail(data, articleDetailColumns, json(), 'Article');
|
|
724
1323
|
});
|
|
725
1324
|
articles
|
|
726
1325
|
.command('create')
|
|
@@ -748,7 +1347,7 @@ Examples:
|
|
|
748
1347
|
return;
|
|
749
1348
|
}
|
|
750
1349
|
const data = await createArticle(params);
|
|
751
|
-
outputDetail(data, articleDetailColumns, json());
|
|
1350
|
+
outputDetail(data, articleDetailColumns, json(), 'Article');
|
|
752
1351
|
});
|
|
753
1352
|
articles
|
|
754
1353
|
.command('update <articleNumber>')
|
|
@@ -767,7 +1366,7 @@ Examples:
|
|
|
767
1366
|
return;
|
|
768
1367
|
}
|
|
769
1368
|
const data = await updateArticle(articleNumber, fields);
|
|
770
|
-
outputDetail(data, articleDetailColumns, json());
|
|
1369
|
+
outputDetail(data, articleDetailColumns, json(), 'Article');
|
|
771
1370
|
});
|
|
772
1371
|
// --- suppliers ---
|
|
773
1372
|
const suppliers = program.command('suppliers').description('Supplier operations');
|
|
@@ -795,7 +1394,7 @@ suppliers
|
|
|
795
1394
|
.action(async (supplierNumber) => {
|
|
796
1395
|
const { getSupplier } = await import('./operations/suppliers.js');
|
|
797
1396
|
const data = await getSupplier(supplierNumber);
|
|
798
|
-
outputDetail(data, supplierDetailColumns, json());
|
|
1397
|
+
outputDetail(data, supplierDetailColumns, json(), 'Supplier');
|
|
799
1398
|
});
|
|
800
1399
|
suppliers
|
|
801
1400
|
.command('create')
|
|
@@ -820,7 +1419,7 @@ Examples:
|
|
|
820
1419
|
return;
|
|
821
1420
|
}
|
|
822
1421
|
const data = await createSupplier(params);
|
|
823
|
-
outputDetail(data, supplierDetailColumns, json());
|
|
1422
|
+
outputDetail(data, supplierDetailColumns, json(), 'Supplier');
|
|
824
1423
|
});
|
|
825
1424
|
suppliers
|
|
826
1425
|
.command('update <supplierNumber>')
|
|
@@ -839,7 +1438,7 @@ Examples:
|
|
|
839
1438
|
return;
|
|
840
1439
|
}
|
|
841
1440
|
const data = await updateSupplier(supplierNumber, fields);
|
|
842
|
-
outputDetail(data, supplierDetailColumns, json());
|
|
1441
|
+
outputDetail(data, supplierDetailColumns, json(), 'Supplier');
|
|
843
1442
|
});
|
|
844
1443
|
// --- supplier-invoices ---
|
|
845
1444
|
const supplierInvoices = program
|
|
@@ -853,6 +1452,7 @@ supplierInvoices
|
|
|
853
1452
|
.option('--supplier <number>', 'Filter by supplier number')
|
|
854
1453
|
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
855
1454
|
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
1455
|
+
.option('--period <period>', 'Natural period (calendar-year): Q1, 2025-Q3, march/mars, this-quarter, last-quarter, this-month, last-month, ytd, this-year, last-year, or a bare year. Mutually exclusive with --from/--to.')
|
|
856
1456
|
.option('--page <number>', 'Page number', parseInt)
|
|
857
1457
|
.option('--limit <number>', 'Results per page', parseInt)
|
|
858
1458
|
.option('-a, --all', 'Fetch all pages')
|
|
@@ -861,8 +1461,7 @@ supplierInvoices
|
|
|
861
1461
|
const data = await listSupplierInvoices({
|
|
862
1462
|
filter: opts.filter,
|
|
863
1463
|
supplierNumber: opts.supplier,
|
|
864
|
-
|
|
865
|
-
toDate: opts.to,
|
|
1464
|
+
...fromToParams(opts),
|
|
866
1465
|
page: opts.page,
|
|
867
1466
|
limit: opts.limit,
|
|
868
1467
|
all: opts.all,
|
|
@@ -876,7 +1475,7 @@ supplierInvoices
|
|
|
876
1475
|
.action(async (givenNumber) => {
|
|
877
1476
|
const { getSupplierInvoice } = await import('./operations/supplier-invoices.js');
|
|
878
1477
|
const data = await getSupplierInvoice(givenNumber);
|
|
879
|
-
outputDetail(data, supplierInvoiceDetailColumns, json());
|
|
1478
|
+
outputDetail(data, supplierInvoiceDetailColumns, json(), 'SupplierInvoice');
|
|
880
1479
|
});
|
|
881
1480
|
supplierInvoices
|
|
882
1481
|
.command('create')
|
|
@@ -899,7 +1498,7 @@ Examples:
|
|
|
899
1498
|
return;
|
|
900
1499
|
}
|
|
901
1500
|
const data = await createSupplierInvoice(params);
|
|
902
|
-
outputDetail(data, supplierInvoiceDetailColumns, json());
|
|
1501
|
+
outputDetail(data, supplierInvoiceDetailColumns, json(), 'SupplierInvoice');
|
|
903
1502
|
});
|
|
904
1503
|
supplierInvoices
|
|
905
1504
|
.command('bookkeep <givenNumber>')
|
|
@@ -912,7 +1511,7 @@ supplierInvoices
|
|
|
912
1511
|
return;
|
|
913
1512
|
}
|
|
914
1513
|
const data = await bookkeepSupplierInvoice(givenNumber);
|
|
915
|
-
outputConfirmation(`Supplier invoice ${givenNumber} bookkeept.`, json(), data, supplierInvoiceConfirmColumns);
|
|
1514
|
+
outputConfirmation(`Supplier invoice ${givenNumber} bookkeept.`, json(), data, supplierInvoiceConfirmColumns, 'SupplierInvoice');
|
|
916
1515
|
});
|
|
917
1516
|
// --- company ---
|
|
918
1517
|
const company = program.command('company').description('Company operations');
|
|
@@ -922,7 +1521,7 @@ company
|
|
|
922
1521
|
.action(async () => {
|
|
923
1522
|
const { getCompanyInfo } = await import('./operations/company.js');
|
|
924
1523
|
const data = await getCompanyInfo();
|
|
925
|
-
outputDetail(data, companyDetailColumns, json());
|
|
1524
|
+
outputDetail(data, companyDetailColumns, json(), 'CompanyInformation');
|
|
926
1525
|
});
|
|
927
1526
|
// --- vouchers ---
|
|
928
1527
|
const vouchers = program.command('vouchers').description('Voucher operations');
|
|
@@ -932,6 +1531,7 @@ vouchers
|
|
|
932
1531
|
.option('--series <series>', 'Voucher series (e.g. "A")')
|
|
933
1532
|
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
934
1533
|
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
1534
|
+
.option('--period <period>', 'Natural period (calendar-year): Q1, 2025-Q3, march/mars, this-quarter, last-quarter, this-month, last-month, ytd, this-year, last-year, or a bare year. Mutually exclusive with --from/--to.')
|
|
935
1535
|
.option('--year <number>', 'Financial year', parseInt)
|
|
936
1536
|
.option('--page <number>', 'Page number', parseInt)
|
|
937
1537
|
.option('--limit <number>', 'Results per page', parseInt)
|
|
@@ -940,8 +1540,7 @@ vouchers
|
|
|
940
1540
|
const { listVouchers } = await import('./operations/vouchers.js');
|
|
941
1541
|
const data = await listVouchers({
|
|
942
1542
|
series: opts.series,
|
|
943
|
-
|
|
944
|
-
toDate: opts.to,
|
|
1543
|
+
...fromToParams(opts),
|
|
945
1544
|
financialYear: opts.year,
|
|
946
1545
|
page: opts.page,
|
|
947
1546
|
limit: opts.limit,
|
|
@@ -958,7 +1557,7 @@ vouchers
|
|
|
958
1557
|
const { getVoucher } = await import('./operations/vouchers.js');
|
|
959
1558
|
const data = await getVoucher(series, voucherNumber, opts.year);
|
|
960
1559
|
if (json()) {
|
|
961
|
-
console.log(JSON.stringify(data, null, 2));
|
|
1560
|
+
console.log(JSON.stringify({ Voucher: data }, null, 2));
|
|
962
1561
|
}
|
|
963
1562
|
else {
|
|
964
1563
|
outputDetail(data, voucherDetailColumns, false);
|
|
@@ -988,25 +1587,996 @@ Examples:
|
|
|
988
1587
|
return;
|
|
989
1588
|
}
|
|
990
1589
|
const data = await createVoucher(params);
|
|
991
|
-
outputDetail(data, voucherDetailColumns, json());
|
|
1590
|
+
outputDetail(data, voucherDetailColumns, json(), 'Voucher');
|
|
992
1591
|
});
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1592
|
+
vouchers
|
|
1593
|
+
.command('attach <series> <number> <files...>')
|
|
1594
|
+
.description('Upload receipt/underlag files and link them to a voucher')
|
|
1595
|
+
.option('--year <number>', 'Financial year (resolved from the voucher date if omitted)', parseInt)
|
|
1596
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1597
|
+
.option('--dry-run', 'Preview without uploading')
|
|
1598
|
+
.addHelpText('after', `
|
|
1599
|
+
Examples:
|
|
1600
|
+
noxctl vouchers attach A 60 receipt.pdf
|
|
1601
|
+
noxctl vouchers attach A 61 ica.pdf lunch.jpg --year 4`)
|
|
1602
|
+
.action(async (series, voucherNumber, files, opts) => {
|
|
1603
|
+
const { attachVoucherFiles } = await import('./operations/vouchers.js');
|
|
1604
|
+
if (!(await confirmMutation(`Attach ${files.length} file(s) to voucher ${series}/${voucherNumber}`, opts, { files, year: opts.year }))) {
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
const results = await attachVoucherFiles({
|
|
1608
|
+
series,
|
|
1609
|
+
voucherNumber,
|
|
1610
|
+
filePaths: files,
|
|
1611
|
+
financialYear: opts.year,
|
|
1612
|
+
});
|
|
1613
|
+
if (json()) {
|
|
1614
|
+
console.log(JSON.stringify({ Attachments: results }, null, 2));
|
|
1615
|
+
}
|
|
1616
|
+
else {
|
|
1617
|
+
outputList(results, voucherAttachmentColumns, false, results);
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
// --- invoice-payments ---
|
|
1621
|
+
const invoicePayments = program
|
|
1622
|
+
.command('invoice-payments')
|
|
1623
|
+
.alias('ip')
|
|
1624
|
+
.description('Invoice payment operations (inbetalningar)');
|
|
1625
|
+
invoicePayments
|
|
1626
|
+
.command('list')
|
|
1627
|
+
.description('List invoice payments')
|
|
1628
|
+
.option('--invoice <number>', 'Filter by invoice number')
|
|
1629
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
1630
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
1631
|
+
.option('-a, --all', 'Fetch all pages')
|
|
1632
|
+
.action(async (opts) => {
|
|
1633
|
+
const { listInvoicePayments } = await import('./operations/invoice-payments.js');
|
|
1634
|
+
const data = await listInvoicePayments({
|
|
1635
|
+
invoiceNumber: opts.invoice,
|
|
1636
|
+
page: opts.page,
|
|
1637
|
+
limit: opts.limit,
|
|
1638
|
+
all: opts.all,
|
|
1639
|
+
});
|
|
1640
|
+
const envelope = data;
|
|
1641
|
+
outputList(envelope.InvoicePayments ?? [], invoicePaymentListColumns, json(), data, envelope.MetaInformation);
|
|
1642
|
+
});
|
|
1643
|
+
invoicePayments
|
|
1644
|
+
.command('get <paymentNumber>')
|
|
1645
|
+
.description('Get a single invoice payment')
|
|
1646
|
+
.action(async (paymentNumber) => {
|
|
1647
|
+
const { getInvoicePayment } = await import('./operations/invoice-payments.js');
|
|
1648
|
+
const data = await getInvoicePayment(paymentNumber);
|
|
1649
|
+
outputDetail(data, invoicePaymentDetailColumns, json(), 'InvoicePayment');
|
|
1650
|
+
});
|
|
1651
|
+
invoicePayments
|
|
1652
|
+
.command('create')
|
|
1653
|
+
.description('Register a payment against an invoice')
|
|
1654
|
+
.requiredOption('--invoice <number>', 'Invoice number')
|
|
1655
|
+
.requiredOption('--amount <amount>', 'Payment amount', parseFloat)
|
|
1656
|
+
.requiredOption('--date <date>', 'Payment date (YYYY-MM-DD)')
|
|
1657
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1658
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
1659
|
+
.addHelpText('after', `
|
|
1660
|
+
Examples:
|
|
1661
|
+
noxctl invoice-payments create --invoice 1001 --amount 5000 --date 2026-03-20 --dry-run
|
|
1662
|
+
noxctl ip create --invoice 1001 --amount 5000 --date 2026-03-20 --yes`)
|
|
1663
|
+
.action(async (opts) => {
|
|
1664
|
+
const { createInvoicePayment } = await import('./operations/invoice-payments.js');
|
|
1665
|
+
const params = {
|
|
1666
|
+
InvoiceNumber: parseInt(opts.invoice, 10),
|
|
1667
|
+
Amount: opts.amount,
|
|
1668
|
+
PaymentDate: opts.date,
|
|
1669
|
+
};
|
|
1670
|
+
if (!(await confirmMutation(`Register payment of ${opts.amount} for invoice ${opts.invoice}`, opts, {
|
|
1671
|
+
InvoicePayment: params,
|
|
1672
|
+
}))) {
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
const data = await createInvoicePayment(params);
|
|
1676
|
+
outputDetail(data, invoicePaymentDetailColumns, json(), 'InvoicePayment');
|
|
1677
|
+
});
|
|
1678
|
+
invoicePayments
|
|
1679
|
+
.command('delete <paymentNumber>')
|
|
1680
|
+
.description('Delete an invoice payment')
|
|
1681
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1682
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
1683
|
+
.action(async (paymentNumber, opts) => {
|
|
1684
|
+
const { deleteInvoicePayment } = await import('./operations/invoice-payments.js');
|
|
1685
|
+
if (!(await confirmMutation(`Delete invoice payment ${paymentNumber}`, opts))) {
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
await deleteInvoicePayment(paymentNumber);
|
|
1689
|
+
outputConfirmation(`Invoice payment ${paymentNumber} deleted.`, json(), { Number: paymentNumber, deleted: true }, undefined, 'InvoicePayment');
|
|
1690
|
+
});
|
|
1691
|
+
invoicePayments
|
|
1692
|
+
.command('bookkeep <paymentNumber>')
|
|
1693
|
+
.description('Bookkeep an invoice payment')
|
|
1694
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1695
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
1696
|
+
.action(async (paymentNumber, opts) => {
|
|
1697
|
+
const { bookkeepInvoicePayment } = await import('./operations/invoice-payments.js');
|
|
1698
|
+
if (!(await confirmMutation(`Bookkeep invoice payment ${paymentNumber}`, opts))) {
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
const result = await bookkeepInvoicePayment(paymentNumber);
|
|
1702
|
+
outputConfirmation(`Invoice payment ${paymentNumber} bookkeept.`, json(), result, undefined, 'InvoicePayment');
|
|
1703
|
+
});
|
|
1704
|
+
// --- supplier-invoice-payments ---
|
|
1705
|
+
const supplierInvoicePayments = program
|
|
1706
|
+
.command('supplier-invoice-payments')
|
|
1707
|
+
.alias('sip')
|
|
1708
|
+
.description('Supplier invoice payment operations (utbetalningar)');
|
|
1709
|
+
supplierInvoicePayments
|
|
1710
|
+
.command('list')
|
|
1711
|
+
.description('List supplier invoice payments')
|
|
1712
|
+
.option('--invoice <number>', 'Filter by invoice number')
|
|
1713
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
1714
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
1715
|
+
.option('-a, --all', 'Fetch all pages')
|
|
1716
|
+
.action(async (opts) => {
|
|
1717
|
+
const { listSupplierInvoicePayments } = await import('./operations/supplier-invoice-payments.js');
|
|
1718
|
+
const data = await listSupplierInvoicePayments({
|
|
1719
|
+
invoiceNumber: opts.invoice,
|
|
1720
|
+
page: opts.page,
|
|
1721
|
+
limit: opts.limit,
|
|
1722
|
+
all: opts.all,
|
|
1723
|
+
});
|
|
1724
|
+
const envelope = data;
|
|
1725
|
+
outputList(envelope.SupplierInvoicePayments ?? [], supplierInvoicePaymentListColumns, json(), data, envelope.MetaInformation);
|
|
1726
|
+
});
|
|
1727
|
+
supplierInvoicePayments
|
|
1728
|
+
.command('get <paymentNumber>')
|
|
1729
|
+
.description('Get a single supplier invoice payment')
|
|
1730
|
+
.action(async (paymentNumber) => {
|
|
1731
|
+
const { getSupplierInvoicePayment } = await import('./operations/supplier-invoice-payments.js');
|
|
1732
|
+
const data = await getSupplierInvoicePayment(paymentNumber);
|
|
1733
|
+
outputDetail(data, supplierInvoicePaymentDetailColumns, json(), 'SupplierInvoicePayment');
|
|
1734
|
+
});
|
|
1735
|
+
supplierInvoicePayments
|
|
1736
|
+
.command('create')
|
|
1737
|
+
.description('Register a payment against a supplier invoice')
|
|
1738
|
+
.requiredOption('--invoice <number>', 'Supplier invoice number')
|
|
1739
|
+
.requiredOption('--amount <amount>', 'Payment amount', parseFloat)
|
|
1740
|
+
.requiredOption('--date <date>', 'Payment date (YYYY-MM-DD)')
|
|
1741
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1742
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
1743
|
+
.addHelpText('after', `
|
|
1744
|
+
Examples:
|
|
1745
|
+
noxctl supplier-invoice-payments create --invoice 501 --amount 3000 --date 2026-03-20 --dry-run
|
|
1746
|
+
noxctl sip create --invoice 501 --amount 3000 --date 2026-03-20 --yes`)
|
|
1747
|
+
.action(async (opts) => {
|
|
1748
|
+
const { createSupplierInvoicePayment } = await import('./operations/supplier-invoice-payments.js');
|
|
1749
|
+
const params = {
|
|
1750
|
+
InvoiceNumber: opts.invoice,
|
|
1751
|
+
Amount: opts.amount,
|
|
1752
|
+
PaymentDate: opts.date,
|
|
1753
|
+
};
|
|
1754
|
+
if (!(await confirmMutation(`Register payment of ${opts.amount} for supplier invoice ${opts.invoice}`, opts, { SupplierInvoicePayment: params }))) {
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
const data = await createSupplierInvoicePayment(params);
|
|
1758
|
+
outputDetail(data, supplierInvoicePaymentDetailColumns, json(), 'SupplierInvoicePayment');
|
|
1759
|
+
});
|
|
1760
|
+
supplierInvoicePayments
|
|
1761
|
+
.command('delete <paymentNumber>')
|
|
1762
|
+
.description('Delete a supplier invoice payment')
|
|
1763
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1764
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
1765
|
+
.action(async (paymentNumber, opts) => {
|
|
1766
|
+
const { deleteSupplierInvoicePayment } = await import('./operations/supplier-invoice-payments.js');
|
|
1767
|
+
if (!(await confirmMutation(`Delete supplier invoice payment ${paymentNumber}`, opts))) {
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
await deleteSupplierInvoicePayment(paymentNumber);
|
|
1771
|
+
outputConfirmation(`Supplier invoice payment ${paymentNumber} deleted.`, json(), { Number: paymentNumber, deleted: true }, undefined, 'SupplierInvoicePayment');
|
|
1772
|
+
});
|
|
1773
|
+
// --- offers ---
|
|
1774
|
+
const offers = program.command('offers').description('Offer/quote operations (offerter)');
|
|
1775
|
+
offers
|
|
1776
|
+
.command('list')
|
|
1777
|
+
.description('List/filter offers')
|
|
1778
|
+
.option('--filter <filter>', 'Filter: cancelled, expired, ordercreated, invoicecreated')
|
|
1779
|
+
.option('--customer <number>', 'Filter by customer number')
|
|
1780
|
+
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
1781
|
+
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
1782
|
+
.option('--period <period>', 'Natural period (calendar-year): Q1, 2025-Q3, march/mars, this-quarter, last-quarter, this-month, last-month, ytd, this-year, last-year, or a bare year. Mutually exclusive with --from/--to.')
|
|
1783
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
1784
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
1785
|
+
.option('-a, --all', 'Fetch all pages')
|
|
1786
|
+
.action(async (opts) => {
|
|
1787
|
+
const { listOffers } = await import('./operations/offers.js');
|
|
1788
|
+
const data = await listOffers({
|
|
1789
|
+
filter: opts.filter,
|
|
1790
|
+
customerNumber: opts.customer,
|
|
1791
|
+
...fromToParams(opts),
|
|
1792
|
+
page: opts.page,
|
|
1793
|
+
limit: opts.limit,
|
|
1794
|
+
all: opts.all,
|
|
1795
|
+
});
|
|
1796
|
+
const envelope = data;
|
|
1797
|
+
outputList(envelope.Offers ?? [], offerListColumns, json(), data, envelope.MetaInformation);
|
|
1798
|
+
});
|
|
1799
|
+
offers
|
|
1800
|
+
.command('get <documentNumber>')
|
|
1801
|
+
.description('Get a single offer')
|
|
1802
|
+
.action(async (documentNumber) => {
|
|
1803
|
+
const { getOffer } = await import('./operations/offers.js');
|
|
1804
|
+
const data = await getOffer(documentNumber);
|
|
1805
|
+
outputDetail(data, offerDetailColumns, json(), 'Offer');
|
|
1806
|
+
});
|
|
1807
|
+
offers
|
|
1808
|
+
.command('create')
|
|
1809
|
+
.description('Create an offer')
|
|
1810
|
+
.requiredOption('--customer <number>', 'Customer number')
|
|
1811
|
+
.requiredOption('--input <file>', 'Offer data as JSON file (or - for stdin)')
|
|
1812
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1813
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
1814
|
+
.addHelpText('after', `
|
|
1815
|
+
Examples:
|
|
1816
|
+
echo '{"OfferRows":[{"Description":"Consulting","DeliveredQuantity":10,"Price":1200}]}' | noxctl offers create --customer 25 --input - --dry-run`)
|
|
1817
|
+
.action(async (opts) => {
|
|
1818
|
+
const { createOffer } = await import('./operations/offers.js');
|
|
1819
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
1820
|
+
const input = JSON.parse(raw);
|
|
1821
|
+
const params = { CustomerNumber: opts.customer, ...input };
|
|
1822
|
+
if (!(await confirmMutation(`Create offer for customer ${opts.customer}`, opts, {
|
|
1823
|
+
Offer: params,
|
|
1824
|
+
}))) {
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
const data = await createOffer(params);
|
|
1828
|
+
outputDetail(data, offerDetailColumns, json(), 'Offer');
|
|
1829
|
+
});
|
|
1830
|
+
offers
|
|
1831
|
+
.command('update <documentNumber>')
|
|
1832
|
+
.description('Update an offer')
|
|
1833
|
+
.requiredOption('--input <file>', 'Offer data as JSON file (or - for stdin)')
|
|
1834
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1835
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
1836
|
+
.action(async (documentNumber, opts) => {
|
|
1837
|
+
const { updateOffer } = await import('./operations/offers.js');
|
|
1838
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
1839
|
+
const fields = JSON.parse(raw);
|
|
1840
|
+
if (!(await confirmMutation(`Update offer ${documentNumber}`, opts, { Offer: fields }))) {
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
const data = await updateOffer(documentNumber, fields);
|
|
1844
|
+
outputDetail(data, offerDetailColumns, json(), 'Offer');
|
|
1845
|
+
});
|
|
1846
|
+
offers
|
|
1847
|
+
.command('create-invoice <documentNumber>')
|
|
1848
|
+
.description('Create an invoice from an offer')
|
|
1849
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1850
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
1851
|
+
.action(async (documentNumber, opts) => {
|
|
1852
|
+
const { createInvoiceFromOffer } = await import('./operations/offers.js');
|
|
1853
|
+
if (!(await confirmMutation(`Create invoice from offer ${documentNumber}`, opts))) {
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
const data = await createInvoiceFromOffer(documentNumber);
|
|
1857
|
+
outputConfirmation(`Invoice created from offer ${documentNumber}.`, json(), data, invoiceConfirmColumns, 'Invoice');
|
|
1858
|
+
});
|
|
1859
|
+
offers
|
|
1860
|
+
.command('create-order <documentNumber>')
|
|
1861
|
+
.description('Create an order from an offer')
|
|
1862
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1863
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
1864
|
+
.action(async (documentNumber, opts) => {
|
|
1865
|
+
const { createOrderFromOffer } = await import('./operations/offers.js');
|
|
1866
|
+
if (!(await confirmMutation(`Create order from offer ${documentNumber}`, opts))) {
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
const data = await createOrderFromOffer(documentNumber);
|
|
1870
|
+
outputConfirmation(`Order created from offer ${documentNumber}.`, json(), data, undefined, 'Order');
|
|
1871
|
+
});
|
|
1872
|
+
// --- orders ---
|
|
1873
|
+
const orders = program.command('orders').description('Order operations (ordrar)');
|
|
1874
|
+
orders
|
|
1875
|
+
.command('list')
|
|
1876
|
+
.description('List/filter orders')
|
|
1877
|
+
.option('--filter <filter>', 'Filter: cancelled, invoicecreated, invoicenotcreated')
|
|
1878
|
+
.option('--customer <number>', 'Filter by customer number')
|
|
1879
|
+
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
1880
|
+
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
1881
|
+
.option('--period <period>', 'Natural period (calendar-year): Q1, 2025-Q3, march/mars, this-quarter, last-quarter, this-month, last-month, ytd, this-year, last-year, or a bare year. Mutually exclusive with --from/--to.')
|
|
1882
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
1883
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
1884
|
+
.option('-a, --all', 'Fetch all pages')
|
|
1885
|
+
.action(async (opts) => {
|
|
1886
|
+
const { listOrders } = await import('./operations/orders.js');
|
|
1887
|
+
const data = await listOrders({
|
|
1888
|
+
filter: opts.filter,
|
|
1889
|
+
customerNumber: opts.customer,
|
|
1890
|
+
...fromToParams(opts),
|
|
1891
|
+
page: opts.page,
|
|
1892
|
+
limit: opts.limit,
|
|
1893
|
+
all: opts.all,
|
|
1894
|
+
});
|
|
1895
|
+
const envelope = data;
|
|
1896
|
+
outputList(envelope.Orders ?? [], orderListColumns, json(), data, envelope.MetaInformation);
|
|
1897
|
+
});
|
|
1898
|
+
orders
|
|
1899
|
+
.command('get <documentNumber>')
|
|
1900
|
+
.description('Get a single order')
|
|
1901
|
+
.action(async (documentNumber) => {
|
|
1902
|
+
const { getOrder } = await import('./operations/orders.js');
|
|
1903
|
+
const data = await getOrder(documentNumber);
|
|
1904
|
+
outputDetail(data, orderDetailColumns, json(), 'Order');
|
|
1905
|
+
});
|
|
1906
|
+
orders
|
|
1907
|
+
.command('create')
|
|
1908
|
+
.description('Create an order')
|
|
1909
|
+
.requiredOption('--customer <number>', 'Customer number')
|
|
1910
|
+
.requiredOption('--input <file>', 'Order data as JSON file (or - for stdin)')
|
|
1911
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1912
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
1913
|
+
.addHelpText('after', `
|
|
1914
|
+
Examples:
|
|
1915
|
+
echo '{"OrderRows":[{"Description":"Consulting","DeliveredQuantity":10,"Price":1200}]}' | noxctl orders create --customer 25 --input - --dry-run`)
|
|
1916
|
+
.action(async (opts) => {
|
|
1917
|
+
const { createOrder } = await import('./operations/orders.js');
|
|
1918
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
1919
|
+
const input = JSON.parse(raw);
|
|
1920
|
+
const params = { CustomerNumber: opts.customer, ...input };
|
|
1921
|
+
if (!(await confirmMutation(`Create order for customer ${opts.customer}`, opts, {
|
|
1922
|
+
Order: params,
|
|
1923
|
+
}))) {
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
const data = await createOrder(params);
|
|
1927
|
+
outputDetail(data, orderDetailColumns, json(), 'Order');
|
|
1928
|
+
});
|
|
1929
|
+
orders
|
|
1930
|
+
.command('update <documentNumber>')
|
|
1931
|
+
.description('Update an order')
|
|
1932
|
+
.requiredOption('--input <file>', 'Order data as JSON file (or - for stdin)')
|
|
1933
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1934
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
1935
|
+
.action(async (documentNumber, opts) => {
|
|
1936
|
+
const { updateOrder } = await import('./operations/orders.js');
|
|
1937
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
1938
|
+
const fields = JSON.parse(raw);
|
|
1939
|
+
if (!(await confirmMutation(`Update order ${documentNumber}`, opts, { Order: fields }))) {
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
const data = await updateOrder(documentNumber, fields);
|
|
1943
|
+
outputDetail(data, orderDetailColumns, json(), 'Order');
|
|
1944
|
+
});
|
|
1945
|
+
orders
|
|
1946
|
+
.command('create-invoice <documentNumber>')
|
|
1947
|
+
.description('Create an invoice from an order')
|
|
1948
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1949
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
1950
|
+
.action(async (documentNumber, opts) => {
|
|
1951
|
+
const { createInvoiceFromOrder } = await import('./operations/orders.js');
|
|
1952
|
+
if (!(await confirmMutation(`Create invoice from order ${documentNumber}`, opts))) {
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
const data = await createInvoiceFromOrder(documentNumber);
|
|
1956
|
+
outputConfirmation(`Invoice created from order ${documentNumber}.`, json(), data, invoiceConfirmColumns, 'Invoice');
|
|
1957
|
+
});
|
|
1958
|
+
// --- projects ---
|
|
1959
|
+
const projects = program.command('projects').description('Project operations');
|
|
1960
|
+
projects
|
|
1961
|
+
.command('list')
|
|
1962
|
+
.description('List projects')
|
|
1963
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
1964
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
1965
|
+
.option('-a, --all', 'Fetch all pages')
|
|
1966
|
+
.action(async (opts) => {
|
|
1967
|
+
const { listProjects } = await import('./operations/projects.js');
|
|
1968
|
+
const data = await listProjects({
|
|
1969
|
+
page: opts.page,
|
|
1970
|
+
limit: opts.limit,
|
|
1971
|
+
all: opts.all,
|
|
1972
|
+
});
|
|
1973
|
+
const envelope = data;
|
|
1974
|
+
outputList(envelope.Projects ?? [], projectListColumns, json(), data, envelope.MetaInformation);
|
|
1975
|
+
});
|
|
1976
|
+
projects
|
|
1977
|
+
.command('get <projectNumber>')
|
|
1978
|
+
.description('Get a single project')
|
|
1979
|
+
.action(async (projectNumber) => {
|
|
1980
|
+
const { getProject } = await import('./operations/projects.js');
|
|
1981
|
+
const data = await getProject(projectNumber);
|
|
1982
|
+
outputDetail(data, projectDetailColumns, json(), 'Project');
|
|
1983
|
+
});
|
|
1984
|
+
projects
|
|
1985
|
+
.command('create')
|
|
1986
|
+
.description('Create a project')
|
|
1987
|
+
.requiredOption('--description <text>', 'Project description')
|
|
1988
|
+
.option('--project-number <number>', 'Project number (auto-generated if omitted)')
|
|
1989
|
+
.option('--status <status>', 'Status (ONGOING or COMPLETED)')
|
|
1990
|
+
.option('--input <file>', 'Project data as JSON file (or - for stdin)')
|
|
1991
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
1992
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
1993
|
+
.action(async (opts) => {
|
|
1994
|
+
const { createProject } = await import('./operations/projects.js');
|
|
1995
|
+
let input = {};
|
|
1996
|
+
if (opts.input) {
|
|
1997
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
1998
|
+
input = JSON.parse(raw);
|
|
1999
|
+
}
|
|
2000
|
+
const params = { ...input, Description: opts.description };
|
|
2001
|
+
if (opts.projectNumber)
|
|
2002
|
+
params.ProjectNumber = opts.projectNumber;
|
|
2003
|
+
if (opts.status)
|
|
2004
|
+
params.Status = opts.status;
|
|
2005
|
+
if (!(await confirmMutation(`Create project "${opts.description}"`, opts, { Project: params }))) {
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
const data = await createProject(params);
|
|
2009
|
+
outputDetail(data, projectDetailColumns, json(), 'Project');
|
|
2010
|
+
});
|
|
2011
|
+
projects
|
|
2012
|
+
.command('update <projectNumber>')
|
|
2013
|
+
.description('Update a project')
|
|
2014
|
+
.requiredOption('--input <file>', 'Project data as JSON file (or - for stdin)')
|
|
2015
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2016
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
2017
|
+
.action(async (projectNumber, opts) => {
|
|
2018
|
+
const { updateProject } = await import('./operations/projects.js');
|
|
2019
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
2020
|
+
const fields = JSON.parse(raw);
|
|
2021
|
+
if (!(await confirmMutation(`Update project ${projectNumber}`, opts, { Project: fields }))) {
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
const data = await updateProject(projectNumber, fields);
|
|
2025
|
+
outputDetail(data, projectDetailColumns, json(), 'Project');
|
|
2026
|
+
});
|
|
2027
|
+
// --- cost centers ---
|
|
2028
|
+
const costcenters = program.command('costcenters').description('Cost center operations');
|
|
2029
|
+
costcenters
|
|
2030
|
+
.command('list')
|
|
2031
|
+
.description('List cost centers')
|
|
2032
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
2033
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
2034
|
+
.option('-a, --all', 'Fetch all pages')
|
|
2035
|
+
.action(async (opts) => {
|
|
2036
|
+
const { listCostCenters } = await import('./operations/costcenters.js');
|
|
2037
|
+
const data = await listCostCenters({
|
|
2038
|
+
page: opts.page,
|
|
2039
|
+
limit: opts.limit,
|
|
2040
|
+
all: opts.all,
|
|
2041
|
+
});
|
|
2042
|
+
const envelope = data;
|
|
2043
|
+
outputList(envelope.CostCenters ?? [], costCenterListColumns, json(), data, envelope.MetaInformation);
|
|
2044
|
+
});
|
|
2045
|
+
costcenters
|
|
2046
|
+
.command('get <code>')
|
|
2047
|
+
.description('Get a single cost center')
|
|
2048
|
+
.action(async (code) => {
|
|
2049
|
+
const { getCostCenter } = await import('./operations/costcenters.js');
|
|
2050
|
+
const data = await getCostCenter(code);
|
|
2051
|
+
outputDetail(data, costCenterDetailColumns, json(), 'CostCenter');
|
|
2052
|
+
});
|
|
2053
|
+
costcenters
|
|
2054
|
+
.command('create')
|
|
2055
|
+
.description('Create a cost center')
|
|
2056
|
+
.requiredOption('--code <code>', 'Cost center code')
|
|
2057
|
+
.requiredOption('--description <text>', 'Description')
|
|
2058
|
+
.option('--input <file>', 'Cost center data as JSON file (or - for stdin)')
|
|
2059
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2060
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
2061
|
+
.action(async (opts) => {
|
|
2062
|
+
const { createCostCenter } = await import('./operations/costcenters.js');
|
|
2063
|
+
let input = {};
|
|
2064
|
+
if (opts.input) {
|
|
2065
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
2066
|
+
input = JSON.parse(raw);
|
|
2067
|
+
}
|
|
2068
|
+
const params = {
|
|
2069
|
+
...input,
|
|
2070
|
+
Code: opts.code,
|
|
2071
|
+
Description: opts.description,
|
|
2072
|
+
};
|
|
2073
|
+
if (!(await confirmMutation(`Create cost center "${opts.code}"`, opts, { CostCenter: params }))) {
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
const data = await createCostCenter(params);
|
|
2077
|
+
outputDetail(data, costCenterDetailColumns, json(), 'CostCenter');
|
|
2078
|
+
});
|
|
2079
|
+
costcenters
|
|
2080
|
+
.command('update <code>')
|
|
2081
|
+
.description('Update a cost center')
|
|
2082
|
+
.requiredOption('--input <file>', 'Cost center data as JSON file (or - for stdin)')
|
|
2083
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2084
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
2085
|
+
.action(async (code, opts) => {
|
|
2086
|
+
const { updateCostCenter } = await import('./operations/costcenters.js');
|
|
2087
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
2088
|
+
const fields = JSON.parse(raw);
|
|
2089
|
+
if (!(await confirmMutation(`Update cost center ${code}`, opts, { CostCenter: fields }))) {
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
const data = await updateCostCenter(code, fields);
|
|
2093
|
+
outputDetail(data, costCenterDetailColumns, json(), 'CostCenter');
|
|
2094
|
+
});
|
|
2095
|
+
costcenters
|
|
2096
|
+
.command('delete <code>')
|
|
2097
|
+
.description('Delete a cost center')
|
|
2098
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2099
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
2100
|
+
.action(async (code, opts) => {
|
|
2101
|
+
const { deleteCostCenter } = await import('./operations/costcenters.js');
|
|
2102
|
+
if (!(await confirmMutation(`Delete cost center ${code}`, opts))) {
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
await deleteCostCenter(code);
|
|
2106
|
+
outputConfirmation(`Cost center ${code} deleted.`, json(), { Code: code, deleted: true }, undefined, 'CostCenter');
|
|
2107
|
+
});
|
|
2108
|
+
// --- tax reductions (ROT/RUT) ---
|
|
2109
|
+
const taxreductions = program
|
|
2110
|
+
.command('tax-reductions')
|
|
2111
|
+
.description('Tax reduction (ROT/RUT) operations');
|
|
2112
|
+
taxreductions
|
|
2113
|
+
.command('list')
|
|
2114
|
+
.description('List tax reductions')
|
|
2115
|
+
.option('--filter <type>', 'Filter by document type (invoices, offers, orders)')
|
|
2116
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
2117
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
2118
|
+
.option('-a, --all', 'Fetch all pages')
|
|
2119
|
+
.action(async (opts) => {
|
|
2120
|
+
const { listTaxReductions } = await import('./operations/taxreductions.js');
|
|
2121
|
+
const data = await listTaxReductions({
|
|
2122
|
+
filter: opts.filter,
|
|
2123
|
+
page: opts.page,
|
|
2124
|
+
limit: opts.limit,
|
|
2125
|
+
all: opts.all,
|
|
2126
|
+
});
|
|
2127
|
+
const envelope = data;
|
|
2128
|
+
outputList(envelope.TaxReductions ?? [], taxReductionListColumns, json(), data, envelope.MetaInformation);
|
|
2129
|
+
});
|
|
2130
|
+
taxreductions
|
|
2131
|
+
.command('get <id>')
|
|
2132
|
+
.description('Get a single tax reduction')
|
|
2133
|
+
.action(async (id) => {
|
|
2134
|
+
const { getTaxReduction } = await import('./operations/taxreductions.js');
|
|
2135
|
+
const data = await getTaxReduction(parseInt(id, 10));
|
|
2136
|
+
outputDetail(data, taxReductionDetailColumns, json(), 'TaxReduction');
|
|
2137
|
+
});
|
|
2138
|
+
taxreductions
|
|
2139
|
+
.command('create')
|
|
2140
|
+
.description('Create a tax reduction (ROT/RUT)')
|
|
2141
|
+
.requiredOption('--reference <number>', 'Reference number (e.g. invoice number)')
|
|
2142
|
+
.requiredOption('--type <type>', 'Type of reduction (rot or rut)')
|
|
2143
|
+
.requiredOption('--document-type <type>', 'Document type (INVOICE, OFFER, ORDER)')
|
|
2144
|
+
.requiredOption('--customer-name <name>', 'Customer name')
|
|
2145
|
+
.requiredOption('--amount <amount>', 'Asked amount in öre', parseInt)
|
|
2146
|
+
.option('--property <designation>', 'Property designation (required for ROT)')
|
|
2147
|
+
.option('--input <file>', 'Tax reduction data as JSON file (or - for stdin)')
|
|
2148
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2149
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
2150
|
+
.action(async (opts) => {
|
|
2151
|
+
const { createTaxReduction } = await import('./operations/taxreductions.js');
|
|
2152
|
+
let input = {};
|
|
2153
|
+
if (opts.input) {
|
|
2154
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
2155
|
+
input = JSON.parse(raw);
|
|
2156
|
+
}
|
|
2157
|
+
const params = {
|
|
2158
|
+
...input,
|
|
2159
|
+
ReferenceNumber: opts.reference,
|
|
2160
|
+
ReferenceDocumentType: opts.documentType,
|
|
2161
|
+
TypeOfReduction: opts.type,
|
|
2162
|
+
CustomerName: opts.customerName,
|
|
2163
|
+
AskedAmount: opts.amount,
|
|
2164
|
+
};
|
|
2165
|
+
if (opts.property)
|
|
2166
|
+
params.PropertyDesignation = opts.property;
|
|
2167
|
+
if (!(await confirmMutation(`Create ${opts.type.toUpperCase()} tax reduction for ref ${opts.reference}`, opts, { TaxReduction: params }))) {
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
const data = await createTaxReduction(params);
|
|
2171
|
+
outputDetail(data, taxReductionDetailColumns, json(), 'TaxReduction');
|
|
2172
|
+
});
|
|
2173
|
+
// --- price lists ---
|
|
2174
|
+
const pricelists = program.command('pricelists').description('Price list operations');
|
|
2175
|
+
pricelists
|
|
2176
|
+
.command('list')
|
|
2177
|
+
.description('List price lists')
|
|
2178
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
2179
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
2180
|
+
.option('-a, --all', 'Fetch all pages')
|
|
2181
|
+
.action(async (opts) => {
|
|
2182
|
+
const { listPriceLists } = await import('./operations/pricelists.js');
|
|
2183
|
+
const data = await listPriceLists({
|
|
2184
|
+
page: opts.page,
|
|
2185
|
+
limit: opts.limit,
|
|
2186
|
+
all: opts.all,
|
|
2187
|
+
});
|
|
2188
|
+
const envelope = data;
|
|
2189
|
+
outputList(envelope.PriceLists ?? [], priceListListColumns, json(), data, envelope.MetaInformation);
|
|
2190
|
+
});
|
|
2191
|
+
pricelists
|
|
2192
|
+
.command('get <code>')
|
|
2193
|
+
.description('Get a single price list')
|
|
2194
|
+
.action(async (code) => {
|
|
2195
|
+
const { getPriceList } = await import('./operations/pricelists.js');
|
|
2196
|
+
const data = await getPriceList(code);
|
|
2197
|
+
outputDetail(data, priceListDetailColumns, json(), 'PriceList');
|
|
2198
|
+
});
|
|
2199
|
+
pricelists
|
|
2200
|
+
.command('create')
|
|
2201
|
+
.description('Create a price list')
|
|
2202
|
+
.requiredOption('--code <code>', 'Price list code')
|
|
2203
|
+
.requiredOption('--description <text>', 'Description')
|
|
2204
|
+
.option('--input <file>', 'Price list data as JSON file (or - for stdin)')
|
|
2205
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2206
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
2207
|
+
.action(async (opts) => {
|
|
2208
|
+
const { createPriceList } = await import('./operations/pricelists.js');
|
|
2209
|
+
let input = {};
|
|
2210
|
+
if (opts.input) {
|
|
2211
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
2212
|
+
input = JSON.parse(raw);
|
|
2213
|
+
}
|
|
2214
|
+
const params = {
|
|
2215
|
+
...input,
|
|
2216
|
+
Code: opts.code,
|
|
2217
|
+
Description: opts.description,
|
|
2218
|
+
};
|
|
2219
|
+
if (!(await confirmMutation(`Create price list "${opts.code}"`, opts, { PriceList: params }))) {
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
const data = await createPriceList(params);
|
|
2223
|
+
outputDetail(data, priceListDetailColumns, json(), 'PriceList');
|
|
2224
|
+
});
|
|
2225
|
+
pricelists
|
|
2226
|
+
.command('update <code>')
|
|
2227
|
+
.description('Update a price list')
|
|
2228
|
+
.requiredOption('--input <file>', 'Price list data as JSON file (or - for stdin)')
|
|
2229
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2230
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
2231
|
+
.action(async (code, opts) => {
|
|
2232
|
+
const { updatePriceList } = await import('./operations/pricelists.js');
|
|
2233
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
2234
|
+
const fields = JSON.parse(raw);
|
|
2235
|
+
if (!(await confirmMutation(`Update price list ${code}`, opts, { PriceList: fields }))) {
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
const data = await updatePriceList(code, fields);
|
|
2239
|
+
outputDetail(data, priceListDetailColumns, json(), 'PriceList');
|
|
2240
|
+
});
|
|
2241
|
+
// --- prices ---
|
|
2242
|
+
const prices = program.command('prices').description('Price operations within price lists');
|
|
2243
|
+
prices
|
|
2244
|
+
.command('list')
|
|
2245
|
+
.description('List prices in a price list')
|
|
2246
|
+
.requiredOption('--pricelist <code>', 'Price list code')
|
|
2247
|
+
.option('--article <number>', 'Filter by article number')
|
|
2248
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
2249
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
2250
|
+
.action(async (opts) => {
|
|
2251
|
+
const { listPrices } = await import('./operations/pricelists.js');
|
|
2252
|
+
const data = await listPrices({
|
|
2253
|
+
priceListCode: opts.pricelist,
|
|
2254
|
+
articleNumber: opts.article,
|
|
2255
|
+
page: opts.page,
|
|
2256
|
+
limit: opts.limit,
|
|
2257
|
+
});
|
|
2258
|
+
const envelope = data;
|
|
2259
|
+
outputList(envelope.Prices ?? [], priceListColumns, json(), data, envelope.MetaInformation);
|
|
2260
|
+
});
|
|
2261
|
+
prices
|
|
2262
|
+
.command('get')
|
|
2263
|
+
.description('Get a specific price')
|
|
2264
|
+
.requiredOption('--pricelist <code>', 'Price list code')
|
|
2265
|
+
.requiredOption('--article <number>', 'Article number')
|
|
2266
|
+
.option('--from-quantity <number>', 'From quantity (default 0)', parseInt)
|
|
2267
|
+
.action(async (opts) => {
|
|
2268
|
+
const { getPrice } = await import('./operations/pricelists.js');
|
|
2269
|
+
const data = await getPrice(opts.pricelist, opts.article, opts.fromQuantity);
|
|
2270
|
+
outputDetail(data, priceDetailColumns, json(), 'Price');
|
|
2271
|
+
});
|
|
2272
|
+
prices
|
|
2273
|
+
.command('update')
|
|
2274
|
+
.description('Update a price')
|
|
2275
|
+
.requiredOption('--pricelist <code>', 'Price list code')
|
|
2276
|
+
.requiredOption('--article <number>', 'Article number')
|
|
2277
|
+
.option('--from-quantity <number>', 'From quantity (default 0)', parseInt)
|
|
2278
|
+
.requiredOption('--input <file>', 'Price data as JSON file (or - for stdin)')
|
|
2279
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2280
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
2281
|
+
.action(async (opts) => {
|
|
2282
|
+
const { updatePrice } = await import('./operations/pricelists.js');
|
|
2283
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
2284
|
+
const fields = JSON.parse(raw);
|
|
2285
|
+
if (!(await confirmMutation(`Update price ${opts.pricelist}/${opts.article}`, opts, {
|
|
2286
|
+
Price: fields,
|
|
2287
|
+
}))) {
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
const data = await updatePrice(opts.pricelist, opts.article, fields, opts.fromQuantity);
|
|
2291
|
+
outputDetail(data, priceDetailColumns, json(), 'Price');
|
|
2292
|
+
});
|
|
2293
|
+
// --- contracts ---
|
|
2294
|
+
const contracts = program
|
|
2295
|
+
.command('contracts')
|
|
2296
|
+
.description('Contract operations (avtal — recurring invoicing)');
|
|
2297
|
+
contracts
|
|
2298
|
+
.command('list')
|
|
2299
|
+
.description('List/filter contracts')
|
|
2300
|
+
.option('--filter <filter>', 'Filter: active, inactive, finished')
|
|
2301
|
+
.option('--page <number>', 'Page number', parseInt)
|
|
2302
|
+
.option('--limit <number>', 'Results per page', parseInt)
|
|
2303
|
+
.option('-a, --all', 'Fetch all pages')
|
|
2304
|
+
.action(async (opts) => {
|
|
2305
|
+
const { listContracts } = await import('./operations/contracts.js');
|
|
2306
|
+
const data = await listContracts({
|
|
2307
|
+
filter: opts.filter,
|
|
2308
|
+
page: opts.page,
|
|
2309
|
+
limit: opts.limit,
|
|
2310
|
+
all: opts.all,
|
|
2311
|
+
});
|
|
2312
|
+
outputList(data.Contracts ?? [], contractListColumns, json(), data, data.MetaInformation);
|
|
2313
|
+
});
|
|
2314
|
+
contracts
|
|
2315
|
+
.command('get <documentNumber>')
|
|
2316
|
+
.description('Get a single contract')
|
|
2317
|
+
.action(async (documentNumber) => {
|
|
2318
|
+
const { getContract } = await import('./operations/contracts.js');
|
|
2319
|
+
const data = await getContract(documentNumber);
|
|
2320
|
+
outputDetail(data, contractDetailColumns, json(), 'Contract');
|
|
2321
|
+
});
|
|
2322
|
+
contracts
|
|
2323
|
+
.command('create')
|
|
2324
|
+
.description('Create a contract (recurring invoicing)')
|
|
2325
|
+
.requiredOption('--customer <number>', 'Customer number')
|
|
2326
|
+
.requiredOption('--input <file>', 'Contract data as JSON file (or - for stdin)')
|
|
2327
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2328
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
2329
|
+
.addHelpText('after', `
|
|
2330
|
+
Examples:
|
|
2331
|
+
echo '{"InvoiceRows":[{"Description":"Hosting","DeliveredQuantity":1,"Price":500,"AccountNumber":3001,"VAT":25}],"PeriodStart":"2026-07-01","PeriodEnd":"2027-06-30","InvoiceInterval":3,"ContractLength":12}' | noxctl contracts create --customer 25 --input - --dry-run`)
|
|
2332
|
+
.action(async (opts) => {
|
|
2333
|
+
const { createContract } = await import('./operations/contracts.js');
|
|
2334
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
2335
|
+
const input = JSON.parse(raw);
|
|
2336
|
+
const params = { CustomerNumber: opts.customer, ...input };
|
|
2337
|
+
if (!(await confirmMutation(`Create contract for customer ${opts.customer}`, opts, {
|
|
2338
|
+
Contract: params,
|
|
2339
|
+
}))) {
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
const data = await createContract(params);
|
|
2343
|
+
outputDetail(data, contractDetailColumns, json(), 'Contract');
|
|
2344
|
+
});
|
|
2345
|
+
contracts
|
|
2346
|
+
.command('update <documentNumber>')
|
|
2347
|
+
.description('Update a contract')
|
|
2348
|
+
.requiredOption('--input <file>', 'Fields to update as JSON file (or - for stdin)')
|
|
2349
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2350
|
+
.option('--dry-run', 'Preview the request without sending it')
|
|
2351
|
+
.action(async (documentNumber, opts) => {
|
|
2352
|
+
const { updateContract } = await import('./operations/contracts.js');
|
|
2353
|
+
const raw = opts.input === '-' ? readFileSync(0, 'utf-8') : readFileSync(opts.input, 'utf-8');
|
|
2354
|
+
const fields = JSON.parse(raw);
|
|
2355
|
+
if (!(await confirmMutation(`Update contract ${documentNumber}`, opts, { Contract: fields }))) {
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
const data = await updateContract(documentNumber, fields);
|
|
2359
|
+
outputDetail(data, contractDetailColumns, json(), 'Contract');
|
|
2360
|
+
});
|
|
2361
|
+
contracts
|
|
2362
|
+
.command('finish <documentNumber>')
|
|
2363
|
+
.description('Finish a contract — no further invoices will be created')
|
|
2364
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2365
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
2366
|
+
.action(async (documentNumber, opts) => {
|
|
2367
|
+
const { finishContract } = await import('./operations/contracts.js');
|
|
2368
|
+
if (!(await confirmMutation(`Finish contract ${documentNumber}`, opts))) {
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
const data = await finishContract(documentNumber);
|
|
2372
|
+
outputConfirmation(`Contract ${documentNumber} finished.`, json(), data, contractDetailColumns, 'Contract');
|
|
2373
|
+
});
|
|
2374
|
+
contracts
|
|
2375
|
+
.command('create-invoice <documentNumber>')
|
|
2376
|
+
.description('Create the next invoice from a contract immediately')
|
|
2377
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2378
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
2379
|
+
.action(async (documentNumber, opts) => {
|
|
2380
|
+
const { createInvoiceFromContract } = await import('./operations/contracts.js');
|
|
2381
|
+
if (!(await confirmMutation(`Create invoice from contract ${documentNumber}`, opts))) {
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
const data = await createInvoiceFromContract(documentNumber);
|
|
2385
|
+
outputConfirmation(`Invoice created from contract ${documentNumber}.`, json(), data, invoiceConfirmColumns, 'Invoice');
|
|
2386
|
+
});
|
|
2387
|
+
contracts
|
|
2388
|
+
.command('increase-invoice-count <documentNumber>')
|
|
2389
|
+
.description('Extend a non-continuous contract by one invoice')
|
|
2390
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
2391
|
+
.option('--dry-run', 'Preview the action without sending it')
|
|
2392
|
+
.action(async (documentNumber, opts) => {
|
|
2393
|
+
const { increaseInvoiceCount } = await import('./operations/contracts.js');
|
|
2394
|
+
if (!(await confirmMutation(`Increase invoice count for contract ${documentNumber}`, opts))) {
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
const data = await increaseInvoiceCount(documentNumber);
|
|
2398
|
+
outputConfirmation(`Invoice count increased for contract ${documentNumber}.`, json(), data, contractDetailColumns, 'Contract');
|
|
2399
|
+
});
|
|
2400
|
+
// --- financial years ---
|
|
2401
|
+
const financialYears = program
|
|
2402
|
+
.command('financial-years')
|
|
2403
|
+
.description('Financial year and locked period operations');
|
|
2404
|
+
financialYears
|
|
2405
|
+
.command('list')
|
|
2406
|
+
.description('List financial years (räkenskapsår)')
|
|
2407
|
+
.option('--date <date>', 'Find the financial year containing this date (YYYY-MM-DD)')
|
|
2408
|
+
.action(async (opts) => {
|
|
2409
|
+
const { listFinancialYears } = await import('./operations/financial-years.js');
|
|
2410
|
+
const data = await listFinancialYears({ date: opts.date });
|
|
2411
|
+
outputList(data.FinancialYears ?? [], financialYearListColumns, json(), data, data.MetaInformation);
|
|
2412
|
+
});
|
|
2413
|
+
financialYears
|
|
2414
|
+
.command('get <id>')
|
|
2415
|
+
.description('Get a single financial year')
|
|
2416
|
+
.action(async (id) => {
|
|
2417
|
+
const { getFinancialYear } = await import('./operations/financial-years.js');
|
|
2418
|
+
const data = await getFinancialYear(parseInt(id, 10));
|
|
2419
|
+
outputDetail(data, financialYearDetailColumns, json(), 'FinancialYear');
|
|
2420
|
+
});
|
|
2421
|
+
financialYears
|
|
2422
|
+
.command('locked-period')
|
|
2423
|
+
.description('Show the locked period (bokföring låst t.o.m.)')
|
|
2424
|
+
.action(async () => {
|
|
2425
|
+
const { getLockedPeriod } = await import('./operations/financial-years.js');
|
|
2426
|
+
const data = await getLockedPeriod();
|
|
2427
|
+
if (json()) {
|
|
2428
|
+
console.log(JSON.stringify({ LockedPeriod: data }, null, 2));
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
if (!data.EndDate) {
|
|
2432
|
+
console.log('No period is locked.');
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
outputDetail(data, lockedPeriodDetailColumns, false);
|
|
2436
|
+
});
|
|
2437
|
+
// --- analytics ---
|
|
2438
|
+
const analytics = program
|
|
2439
|
+
.command('analytics')
|
|
2440
|
+
.description('Precomputed analytics views (overdue, unpaid, top customers, VAT)');
|
|
2441
|
+
analytics
|
|
2442
|
+
.command('overdue')
|
|
2443
|
+
.description('Overdue invoices summary')
|
|
2444
|
+
.action(async () => {
|
|
2445
|
+
const { getOverdueSummary } = await import('./operations/analytics.js');
|
|
2446
|
+
const summary = await getOverdueSummary();
|
|
2447
|
+
if (json()) {
|
|
2448
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
if (summary.count === 0) {
|
|
2452
|
+
console.log('No overdue invoices.');
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
console.log(`Overdue: ${summary.count} invoice(s), ${summary.totalBalance.toFixed(2)} outstanding. Oldest due ${summary.oldestDueDate}.\n`);
|
|
2456
|
+
outputList(summary.invoices, invoiceListColumns, false, summary.invoices);
|
|
2457
|
+
});
|
|
2458
|
+
analytics
|
|
2459
|
+
.command('unpaid')
|
|
2460
|
+
.description('Unpaid totals (outstanding receivables)')
|
|
2461
|
+
.action(async () => {
|
|
2462
|
+
const { getUnpaidTotals } = await import('./operations/analytics.js');
|
|
2463
|
+
const s = await getUnpaidTotals();
|
|
2464
|
+
if (json()) {
|
|
2465
|
+
console.log(JSON.stringify(s, null, 2));
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
console.log(`Unpaid: ${s.count} invoice(s), ${s.totalBalance.toFixed(2)} outstanding.`);
|
|
2469
|
+
console.log(`Overdue: ${s.overdueCount} invoice(s), ${s.overdueBalance.toFixed(2)}.`);
|
|
2470
|
+
});
|
|
2471
|
+
analytics
|
|
2472
|
+
.command('top-customers')
|
|
2473
|
+
.description('Top customers by invoiced amount')
|
|
2474
|
+
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
2475
|
+
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
2476
|
+
.option('--period <period>', 'Natural period (calendar-year): Q1, 2025-Q3, march/mars, last-quarter, ytd, ... Mutually exclusive with --from/--to.')
|
|
2477
|
+
.option('--limit <number>', 'Number of customers (default 10)', parseInt)
|
|
2478
|
+
.action(async (opts) => {
|
|
2479
|
+
const { getTopCustomers } = await import('./operations/analytics.js');
|
|
2480
|
+
const range = fromToParams(opts);
|
|
2481
|
+
const result = await getTopCustomers({
|
|
2482
|
+
fromDate: range.fromDate,
|
|
2483
|
+
toDate: range.toDate,
|
|
2484
|
+
limit: opts.limit,
|
|
2485
|
+
});
|
|
2486
|
+
if (json()) {
|
|
2487
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
outputList(result.customers.map((c) => ({ ...c })), topCustomerColumns, false, result.customers);
|
|
2491
|
+
});
|
|
2492
|
+
analytics
|
|
2493
|
+
.command('vat')
|
|
2494
|
+
.description('VAT summary for a period (net VAT position)')
|
|
2495
|
+
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
2496
|
+
.option('--to <date>', 'To date (YYYY-MM-DD)')
|
|
2497
|
+
.option('--period <period>', 'Natural period (calendar-year): Q1, 2025-Q3, march/mars, last-quarter, ytd, ... Mutually exclusive with --from/--to.')
|
|
2498
|
+
.option('--year <number>', 'Financial year', parseInt)
|
|
2499
|
+
.action(async (opts) => {
|
|
2500
|
+
const { getVatSummary } = await import('./operations/analytics.js');
|
|
2501
|
+
const range = fromToParams(opts);
|
|
2502
|
+
if (!range.fromDate || !range.toDate) {
|
|
2503
|
+
fail('analytics vat requires a period: --from/--to or --period.', 2);
|
|
2504
|
+
}
|
|
2505
|
+
const summary = await getVatSummary({
|
|
2506
|
+
fromDate: range.fromDate,
|
|
2507
|
+
toDate: range.toDate,
|
|
2508
|
+
financialYear: opts.year,
|
|
2509
|
+
});
|
|
2510
|
+
if (json()) {
|
|
2511
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
2512
|
+
return;
|
|
2513
|
+
}
|
|
2514
|
+
console.log(formatTaxReport(summary));
|
|
2515
|
+
console.log(`\nNet VAT: ${summary.netVat.toFixed(2)} (negative = owed to Skatteverket)`);
|
|
2516
|
+
});
|
|
2517
|
+
// --- dashboard ---
|
|
2518
|
+
program
|
|
2519
|
+
.command('dashboard')
|
|
2520
|
+
.description('At-a-glance summary: recent invoices, outstanding, overdue, monthly revenue')
|
|
2521
|
+
.option('--months <number>', 'Months of revenue history (default 6)', parseInt)
|
|
2522
|
+
.action(async (opts) => {
|
|
2523
|
+
const { getDashboard } = await import('./operations/analytics.js');
|
|
2524
|
+
const dash = await getDashboard({ months: opts.months });
|
|
2525
|
+
if (json()) {
|
|
2526
|
+
console.log(JSON.stringify(dash, null, 2));
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
console.log('OUTSTANDING');
|
|
2530
|
+
console.log(` Unpaid: ${dash.unpaid.count} invoice(s), ${dash.unpaid.totalBalance.toFixed(2)}`);
|
|
2531
|
+
console.log(` Overdue: ${dash.overdue.count} invoice(s), ${dash.overdue.totalBalance.toFixed(2)}` +
|
|
2532
|
+
(dash.overdue.oldestDueDate ? ` (oldest due ${dash.overdue.oldestDueDate})` : ''));
|
|
2533
|
+
if (dash.overdue.count > 0) {
|
|
2534
|
+
console.log('\nOVERDUE INVOICES');
|
|
2535
|
+
outputList(dash.overdue.invoices, invoiceListColumns, false, dash.overdue.invoices);
|
|
2536
|
+
}
|
|
2537
|
+
console.log('\nRECENT INVOICES');
|
|
2538
|
+
outputList(dash.recentInvoices, invoiceListColumns, false, dash.recentInvoices);
|
|
2539
|
+
console.log('\nMONTHLY INVOICED');
|
|
2540
|
+
outputList(dash.monthlyRevenue.map((m) => ({ ...m })), monthlyRevenueColumns, false, dash.monthlyRevenue);
|
|
2541
|
+
});
|
|
2542
|
+
// --- completion ---
|
|
2543
|
+
program
|
|
2544
|
+
.command('completion <shell>')
|
|
2545
|
+
.description('Generate shell completion script (bash, zsh, fish)')
|
|
2546
|
+
.addHelpText('after', `
|
|
2547
|
+
Examples:
|
|
2548
|
+
noxctl completion bash > /usr/local/etc/bash_completion.d/noxctl
|
|
2549
|
+
noxctl completion zsh > "\${fpath[1]}/_noxctl"
|
|
2550
|
+
noxctl completion fish > ~/.config/fish/completions/noxctl.fish`)
|
|
2551
|
+
.action(async (shell) => {
|
|
2552
|
+
const { extractCommandTree, renderCompletion } = await import('./completions.js');
|
|
2553
|
+
console.log(renderCompletion(shell, extractCommandTree(program)));
|
|
2554
|
+
});
|
|
2555
|
+
// Error handling (configureOutput + exitOverride set above, before the command
|
|
2556
|
+
// tree, so subcommands inherit them).
|
|
2557
|
+
try {
|
|
2558
|
+
await program.parseAsync(process.argv);
|
|
2559
|
+
}
|
|
2560
|
+
catch (err) {
|
|
2561
|
+
const code = err instanceof Error && 'code' in err ? err.code : undefined;
|
|
2562
|
+
// Commander throws these for --help and --version after writing output; exit 0.
|
|
2563
|
+
if (code === 'commander.helpDisplayed' || code === 'commander.version') {
|
|
2564
|
+
process.exit(0);
|
|
2565
|
+
}
|
|
2566
|
+
// Commander parse errors (unknownCommand, missingMandatoryOptionValue,
|
|
2567
|
+
// excessArguments, ...) carry a `commander.*` code. configureOutput above
|
|
2568
|
+
// already wrote their plain-text usage message in table mode, so we must not
|
|
2569
|
+
// print it again here; in JSON mode that output was suppressed, so we emit
|
|
2570
|
+
// the structured envelope below instead.
|
|
2571
|
+
const isCommanderParseError = typeof code === 'string' && code.startsWith('commander.');
|
|
2572
|
+
if (json()) {
|
|
2573
|
+
// Structured mode fails structured: scripted callers branch on .error
|
|
2574
|
+
// instead of string-scraping stderr.
|
|
2575
|
+
console.error(JSON.stringify(errorEnvelope(err), null, 2));
|
|
2576
|
+
}
|
|
2577
|
+
else if (!isCommanderParseError) {
|
|
2578
|
+
console.error(err instanceof Error ? err.message : err);
|
|
1008
2579
|
}
|
|
1009
|
-
console.error(err instanceof Error ? err.message : err);
|
|
1010
2580
|
process.exit(1);
|
|
1011
2581
|
}
|
|
1012
2582
|
//# sourceMappingURL=cli.js.map
|