noxctl 0.2.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 +93 -0
- package/dist/auth.d.ts +1 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +7 -2
- package/dist/auth.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +729 -91
- 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/credentials-store.d.ts.map +1 -1
- package/dist/credentials-store.js +45 -16
- 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 +14 -3
- package/dist/fortnox-client.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- 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/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/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/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/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/status.d.ts.map +1 -1
- package/dist/tools/status.js +6 -1
- package/dist/tools/status.js.map +1 -1
- package/dist/views.d.ts +8 -0
- package/dist/views.d.ts.map +1 -1
- package/dist/views.js +57 -0
- package/dist/views.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,29 +2,65 @@
|
|
|
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';
|
|
5
|
+
import { isJsonMode, outputList, outputDetail, outputConfirmation, formatTaxReport, formatFinancialReport, errorEnvelope, } from './formatter.js';
|
|
6
6
|
import { readActivePointer, readActivePointerOutcome, writeActivePointer, deleteActivePointer, readProfileIndex, resolveProfile, } from './profiles.js';
|
|
7
7
|
import { setResolvedProfile } from './auth.js';
|
|
8
|
+
import { applyPeriod } from './date-periods.js';
|
|
8
9
|
import { DEFAULT_PROFILE, InvalidProfileNameError } from './profile-name.js';
|
|
9
|
-
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, } from './views.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';
|
|
10
11
|
const program = new Command();
|
|
11
12
|
program
|
|
12
13
|
.name('noxctl')
|
|
13
14
|
.description('CLI and MCP server for Fortnox accounting')
|
|
14
|
-
.version('0.
|
|
15
|
-
.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)')
|
|
16
17
|
.choices(['json', 'table'])
|
|
17
18
|
.default(undefined))
|
|
18
19
|
.option('--profile <name>', 'Profile to operate on (overrides NOXCTL_PROFILE and active pointer)');
|
|
19
20
|
function json() {
|
|
20
21
|
return isJsonMode(program.opts());
|
|
21
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
|
+
}
|
|
22
58
|
let resolvedProfileInfo = { name: DEFAULT_PROFILE, source: 'default' };
|
|
23
59
|
export function getResolvedProfileInfo() {
|
|
24
60
|
return resolvedProfileInfo;
|
|
25
61
|
}
|
|
26
62
|
// Commands that don't need (and shouldn't require) a resolved profile.
|
|
27
|
-
const PROFILE_RESOLUTION_SKIP = new Set(['help']);
|
|
63
|
+
const PROFILE_RESOLUTION_SKIP = new Set(['help', 'completion']);
|
|
28
64
|
program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
29
65
|
const name = actionCommand.name();
|
|
30
66
|
if (PROFILE_RESOLUTION_SKIP.has(name))
|
|
@@ -46,8 +82,7 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
|
46
82
|
// loudly but continue so `doctor` / `profile use` can repair the state.
|
|
47
83
|
const wouldRelyOnPointer = !flag && !env;
|
|
48
84
|
if (wouldRelyOnPointer && name === 'serve') {
|
|
49
|
-
|
|
50
|
-
process.exit(2);
|
|
85
|
+
fail(`Active profile pointer ${desc}. Refusing to start MCP server with ambiguous profile. Run \`noxctl doctor\` or set NOXCTL_PROFILE explicitly.`, 2);
|
|
51
86
|
}
|
|
52
87
|
process.stderr.write(`[warning: active-profile pointer ${desc}; ignoring]\n`);
|
|
53
88
|
}
|
|
@@ -56,8 +91,7 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
|
56
91
|
}
|
|
57
92
|
catch (err) {
|
|
58
93
|
if (err instanceof InvalidProfileNameError) {
|
|
59
|
-
|
|
60
|
-
process.exit(2);
|
|
94
|
+
fail(err.message, 2);
|
|
61
95
|
}
|
|
62
96
|
throw err;
|
|
63
97
|
}
|
|
@@ -108,6 +142,12 @@ async function confirmMutation(action, opts, payload) {
|
|
|
108
142
|
}
|
|
109
143
|
const company = await fetchCompanyHint();
|
|
110
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
|
+
}
|
|
111
151
|
const rl = createInterface({
|
|
112
152
|
input: process.stdin,
|
|
113
153
|
output: process.stdout,
|
|
@@ -120,6 +160,27 @@ async function confirmMutation(action, opts, payload) {
|
|
|
120
160
|
rl.close();
|
|
121
161
|
}
|
|
122
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] `);
|
|
178
|
+
return ['y', 'yes'].includes(answer.trim().toLowerCase());
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
rl.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
123
184
|
// --- init (interactive setup wizard) ---
|
|
124
185
|
program
|
|
125
186
|
.command('init')
|
|
@@ -541,6 +602,24 @@ program
|
|
|
541
602
|
? 'Windows DPAPI'
|
|
542
603
|
: 'Linux Secret Service';
|
|
543
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
|
+
}
|
|
544
623
|
// 2b. Profile resolution
|
|
545
624
|
pass('Profile', `${resolvedProfileInfo.name} (source: ${resolvedProfileInfo.source})`);
|
|
546
625
|
// 2c. Active pointer health — surface a corrupt pointer file even though
|
|
@@ -574,7 +653,19 @@ program
|
|
|
574
653
|
// best-effort diagnostic — don't block doctor on filesystem issues
|
|
575
654
|
}
|
|
576
655
|
// 3. Credentials exist
|
|
577
|
-
|
|
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
|
+
}
|
|
578
669
|
if (!creds) {
|
|
579
670
|
fail('Credentials', `not found for profile "${resolvedProfileInfo.name}" — run \`noxctl init${resolvedProfileInfo.name === DEFAULT_PROFILE
|
|
580
671
|
? ''
|
|
@@ -636,6 +727,8 @@ program
|
|
|
636
727
|
bookkeeping: 'vouchers?limit=1',
|
|
637
728
|
companyinformation: 'companyinformation',
|
|
638
729
|
settings: 'settings/company',
|
|
730
|
+
inbox: 'inbox',
|
|
731
|
+
connectfile: 'voucherfileconnections?limit=1',
|
|
639
732
|
};
|
|
640
733
|
const required = SCOPES.split(' ');
|
|
641
734
|
const missing = [];
|
|
@@ -647,10 +740,13 @@ program
|
|
|
647
740
|
await fortnoxRequest(endpoint);
|
|
648
741
|
}
|
|
649
742
|
catch (err) {
|
|
650
|
-
|
|
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 ?? ''))) {
|
|
651
748
|
missing.push(scope);
|
|
652
749
|
}
|
|
653
|
-
// Non-403 errors (e.g. 500) are not scope problems — ignore here
|
|
654
750
|
}
|
|
655
751
|
}
|
|
656
752
|
if (missing.length === 0) {
|
|
@@ -661,6 +757,245 @@ program
|
|
|
661
757
|
}
|
|
662
758
|
console.log(`\n${ok ? 'All checks passed.' : 'Some checks failed.'}`);
|
|
663
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
|
+
});
|
|
664
999
|
// --- serve (default command) ---
|
|
665
1000
|
program
|
|
666
1001
|
.command('serve', { isDefault: true })
|
|
@@ -678,6 +1013,7 @@ invoices
|
|
|
678
1013
|
.option('--customer <number>', 'Filter by customer number')
|
|
679
1014
|
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
680
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.')
|
|
681
1017
|
.option('--page <number>', 'Page number', parseInt)
|
|
682
1018
|
.option('--limit <number>', 'Results per page', parseInt)
|
|
683
1019
|
.option('-a, --all', 'Fetch all pages')
|
|
@@ -686,8 +1022,7 @@ invoices
|
|
|
686
1022
|
const data = await listInvoices({
|
|
687
1023
|
filter: opts.filter,
|
|
688
1024
|
customerNumber: opts.customer,
|
|
689
|
-
|
|
690
|
-
toDate: opts.to,
|
|
1025
|
+
...fromToParams(opts),
|
|
691
1026
|
page: opts.page,
|
|
692
1027
|
limit: opts.limit,
|
|
693
1028
|
all: opts.all,
|
|
@@ -701,7 +1036,7 @@ invoices
|
|
|
701
1036
|
.action(async (documentNumber) => {
|
|
702
1037
|
const { getInvoice } = await import('./operations/invoices.js');
|
|
703
1038
|
const data = await getInvoice(documentNumber);
|
|
704
|
-
outputDetail(data, invoiceDetailColumns, json());
|
|
1039
|
+
outputDetail(data, invoiceDetailColumns, json(), 'Invoice');
|
|
705
1040
|
});
|
|
706
1041
|
invoices
|
|
707
1042
|
.command('create')
|
|
@@ -727,7 +1062,7 @@ Examples:
|
|
|
727
1062
|
return;
|
|
728
1063
|
}
|
|
729
1064
|
const data = await createInvoice(params);
|
|
730
|
-
outputDetail(data, invoiceDetailColumns, json());
|
|
1065
|
+
outputDetail(data, invoiceDetailColumns, json(), 'Invoice');
|
|
731
1066
|
});
|
|
732
1067
|
invoices
|
|
733
1068
|
.command('update <documentNumber>')
|
|
@@ -747,7 +1082,7 @@ Examples:
|
|
|
747
1082
|
return;
|
|
748
1083
|
}
|
|
749
1084
|
const data = await updateInvoice(documentNumber, fields);
|
|
750
|
-
outputDetail(data, invoiceDetailColumns, json());
|
|
1085
|
+
outputDetail(data, invoiceDetailColumns, json(), 'Invoice');
|
|
751
1086
|
});
|
|
752
1087
|
invoices
|
|
753
1088
|
.command('send <documentNumber>')
|
|
@@ -769,7 +1104,7 @@ invoices
|
|
|
769
1104
|
? { subject: opts.subject, body: opts.body, bcc: opts.bcc }
|
|
770
1105
|
: undefined;
|
|
771
1106
|
const data = await sendInvoice(documentNumber, opts.method, emailOptions);
|
|
772
|
-
outputConfirmation(`Invoice ${documentNumber} sent via ${opts.method}.`, json(), data, invoiceConfirmColumns);
|
|
1107
|
+
outputConfirmation(`Invoice ${documentNumber} sent via ${opts.method}.`, json(), data, invoiceConfirmColumns, 'Invoice');
|
|
773
1108
|
});
|
|
774
1109
|
invoices
|
|
775
1110
|
.command('bookkeep <documentNumber>')
|
|
@@ -782,7 +1117,7 @@ invoices
|
|
|
782
1117
|
return;
|
|
783
1118
|
}
|
|
784
1119
|
const data = await bookkeepInvoice(documentNumber);
|
|
785
|
-
outputConfirmation(`Invoice ${documentNumber} bookkeept.`, json(), data, invoiceConfirmColumns);
|
|
1120
|
+
outputConfirmation(`Invoice ${documentNumber} bookkeept.`, json(), data, invoiceConfirmColumns, 'Invoice');
|
|
786
1121
|
});
|
|
787
1122
|
invoices
|
|
788
1123
|
.command('credit <documentNumber>')
|
|
@@ -795,21 +1130,26 @@ invoices
|
|
|
795
1130
|
return;
|
|
796
1131
|
}
|
|
797
1132
|
const data = await creditInvoice(documentNumber);
|
|
798
|
-
outputConfirmation(`Invoice ${documentNumber} credited.`, json(), data, invoiceConfirmColumns);
|
|
1133
|
+
outputConfirmation(`Invoice ${documentNumber} credited.`, json(), data, invoiceConfirmColumns, 'Invoice');
|
|
799
1134
|
});
|
|
800
1135
|
// --- tax ---
|
|
801
1136
|
const tax = program.command('tax').description('Tax operations');
|
|
802
1137
|
tax
|
|
803
1138
|
.command('report')
|
|
804
1139
|
.description('Generate VAT tax report for a period')
|
|
805
|
-
.
|
|
806
|
-
.
|
|
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.')
|
|
807
1143
|
.option('--year <number>', 'Financial year', parseInt)
|
|
808
1144
|
.action(async (opts) => {
|
|
809
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
|
+
}
|
|
810
1150
|
const data = await generateTaxReport({
|
|
811
|
-
fromDate:
|
|
812
|
-
toDate:
|
|
1151
|
+
fromDate: range.fromDate,
|
|
1152
|
+
toDate: range.toDate,
|
|
813
1153
|
financialYear: opts.year,
|
|
814
1154
|
});
|
|
815
1155
|
if (json()) {
|
|
@@ -830,12 +1170,12 @@ reports
|
|
|
830
1170
|
.option('--year <number>', 'Financial year', parseInt)
|
|
831
1171
|
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
832
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.')
|
|
833
1174
|
.action(async (opts) => {
|
|
834
1175
|
const { getIncomeStatement } = await import('./operations/financial-reports.js');
|
|
835
1176
|
const data = await getIncomeStatement({
|
|
836
1177
|
financialYear: opts.year,
|
|
837
|
-
|
|
838
|
-
toDate: opts.to,
|
|
1178
|
+
...fromToParams(opts),
|
|
839
1179
|
});
|
|
840
1180
|
if (json()) {
|
|
841
1181
|
console.log(JSON.stringify(data, null, 2));
|
|
@@ -907,7 +1247,7 @@ customers
|
|
|
907
1247
|
.action(async (customerNumber) => {
|
|
908
1248
|
const { getCustomer } = await import('./operations/customers.js');
|
|
909
1249
|
const data = await getCustomer(customerNumber);
|
|
910
|
-
outputDetail(data, customerDetailColumns, json());
|
|
1250
|
+
outputDetail(data, customerDetailColumns, json(), 'Customer');
|
|
911
1251
|
});
|
|
912
1252
|
customers
|
|
913
1253
|
.command('create')
|
|
@@ -932,7 +1272,7 @@ Examples:
|
|
|
932
1272
|
return;
|
|
933
1273
|
}
|
|
934
1274
|
const data = await createCustomer(params);
|
|
935
|
-
outputDetail(data, customerDetailColumns, json());
|
|
1275
|
+
outputDetail(data, customerDetailColumns, json(), 'Customer');
|
|
936
1276
|
});
|
|
937
1277
|
customers
|
|
938
1278
|
.command('update <customerNumber>')
|
|
@@ -951,7 +1291,7 @@ Examples:
|
|
|
951
1291
|
return;
|
|
952
1292
|
}
|
|
953
1293
|
const data = await updateCustomer(customerNumber, fields);
|
|
954
|
-
outputDetail(data, customerDetailColumns, json());
|
|
1294
|
+
outputDetail(data, customerDetailColumns, json(), 'Customer');
|
|
955
1295
|
});
|
|
956
1296
|
// --- articles ---
|
|
957
1297
|
const articles = program.command('articles').description('Article operations');
|
|
@@ -979,7 +1319,7 @@ articles
|
|
|
979
1319
|
.action(async (articleNumber) => {
|
|
980
1320
|
const { getArticle } = await import('./operations/articles.js');
|
|
981
1321
|
const data = await getArticle(articleNumber);
|
|
982
|
-
outputDetail(data, articleDetailColumns, json());
|
|
1322
|
+
outputDetail(data, articleDetailColumns, json(), 'Article');
|
|
983
1323
|
});
|
|
984
1324
|
articles
|
|
985
1325
|
.command('create')
|
|
@@ -1007,7 +1347,7 @@ Examples:
|
|
|
1007
1347
|
return;
|
|
1008
1348
|
}
|
|
1009
1349
|
const data = await createArticle(params);
|
|
1010
|
-
outputDetail(data, articleDetailColumns, json());
|
|
1350
|
+
outputDetail(data, articleDetailColumns, json(), 'Article');
|
|
1011
1351
|
});
|
|
1012
1352
|
articles
|
|
1013
1353
|
.command('update <articleNumber>')
|
|
@@ -1026,7 +1366,7 @@ Examples:
|
|
|
1026
1366
|
return;
|
|
1027
1367
|
}
|
|
1028
1368
|
const data = await updateArticle(articleNumber, fields);
|
|
1029
|
-
outputDetail(data, articleDetailColumns, json());
|
|
1369
|
+
outputDetail(data, articleDetailColumns, json(), 'Article');
|
|
1030
1370
|
});
|
|
1031
1371
|
// --- suppliers ---
|
|
1032
1372
|
const suppliers = program.command('suppliers').description('Supplier operations');
|
|
@@ -1054,7 +1394,7 @@ suppliers
|
|
|
1054
1394
|
.action(async (supplierNumber) => {
|
|
1055
1395
|
const { getSupplier } = await import('./operations/suppliers.js');
|
|
1056
1396
|
const data = await getSupplier(supplierNumber);
|
|
1057
|
-
outputDetail(data, supplierDetailColumns, json());
|
|
1397
|
+
outputDetail(data, supplierDetailColumns, json(), 'Supplier');
|
|
1058
1398
|
});
|
|
1059
1399
|
suppliers
|
|
1060
1400
|
.command('create')
|
|
@@ -1079,7 +1419,7 @@ Examples:
|
|
|
1079
1419
|
return;
|
|
1080
1420
|
}
|
|
1081
1421
|
const data = await createSupplier(params);
|
|
1082
|
-
outputDetail(data, supplierDetailColumns, json());
|
|
1422
|
+
outputDetail(data, supplierDetailColumns, json(), 'Supplier');
|
|
1083
1423
|
});
|
|
1084
1424
|
suppliers
|
|
1085
1425
|
.command('update <supplierNumber>')
|
|
@@ -1098,7 +1438,7 @@ Examples:
|
|
|
1098
1438
|
return;
|
|
1099
1439
|
}
|
|
1100
1440
|
const data = await updateSupplier(supplierNumber, fields);
|
|
1101
|
-
outputDetail(data, supplierDetailColumns, json());
|
|
1441
|
+
outputDetail(data, supplierDetailColumns, json(), 'Supplier');
|
|
1102
1442
|
});
|
|
1103
1443
|
// --- supplier-invoices ---
|
|
1104
1444
|
const supplierInvoices = program
|
|
@@ -1112,6 +1452,7 @@ supplierInvoices
|
|
|
1112
1452
|
.option('--supplier <number>', 'Filter by supplier number')
|
|
1113
1453
|
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
1114
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.')
|
|
1115
1456
|
.option('--page <number>', 'Page number', parseInt)
|
|
1116
1457
|
.option('--limit <number>', 'Results per page', parseInt)
|
|
1117
1458
|
.option('-a, --all', 'Fetch all pages')
|
|
@@ -1120,8 +1461,7 @@ supplierInvoices
|
|
|
1120
1461
|
const data = await listSupplierInvoices({
|
|
1121
1462
|
filter: opts.filter,
|
|
1122
1463
|
supplierNumber: opts.supplier,
|
|
1123
|
-
|
|
1124
|
-
toDate: opts.to,
|
|
1464
|
+
...fromToParams(opts),
|
|
1125
1465
|
page: opts.page,
|
|
1126
1466
|
limit: opts.limit,
|
|
1127
1467
|
all: opts.all,
|
|
@@ -1135,7 +1475,7 @@ supplierInvoices
|
|
|
1135
1475
|
.action(async (givenNumber) => {
|
|
1136
1476
|
const { getSupplierInvoice } = await import('./operations/supplier-invoices.js');
|
|
1137
1477
|
const data = await getSupplierInvoice(givenNumber);
|
|
1138
|
-
outputDetail(data, supplierInvoiceDetailColumns, json());
|
|
1478
|
+
outputDetail(data, supplierInvoiceDetailColumns, json(), 'SupplierInvoice');
|
|
1139
1479
|
});
|
|
1140
1480
|
supplierInvoices
|
|
1141
1481
|
.command('create')
|
|
@@ -1158,7 +1498,7 @@ Examples:
|
|
|
1158
1498
|
return;
|
|
1159
1499
|
}
|
|
1160
1500
|
const data = await createSupplierInvoice(params);
|
|
1161
|
-
outputDetail(data, supplierInvoiceDetailColumns, json());
|
|
1501
|
+
outputDetail(data, supplierInvoiceDetailColumns, json(), 'SupplierInvoice');
|
|
1162
1502
|
});
|
|
1163
1503
|
supplierInvoices
|
|
1164
1504
|
.command('bookkeep <givenNumber>')
|
|
@@ -1171,7 +1511,7 @@ supplierInvoices
|
|
|
1171
1511
|
return;
|
|
1172
1512
|
}
|
|
1173
1513
|
const data = await bookkeepSupplierInvoice(givenNumber);
|
|
1174
|
-
outputConfirmation(`Supplier invoice ${givenNumber} bookkeept.`, json(), data, supplierInvoiceConfirmColumns);
|
|
1514
|
+
outputConfirmation(`Supplier invoice ${givenNumber} bookkeept.`, json(), data, supplierInvoiceConfirmColumns, 'SupplierInvoice');
|
|
1175
1515
|
});
|
|
1176
1516
|
// --- company ---
|
|
1177
1517
|
const company = program.command('company').description('Company operations');
|
|
@@ -1181,7 +1521,7 @@ company
|
|
|
1181
1521
|
.action(async () => {
|
|
1182
1522
|
const { getCompanyInfo } = await import('./operations/company.js');
|
|
1183
1523
|
const data = await getCompanyInfo();
|
|
1184
|
-
outputDetail(data, companyDetailColumns, json());
|
|
1524
|
+
outputDetail(data, companyDetailColumns, json(), 'CompanyInformation');
|
|
1185
1525
|
});
|
|
1186
1526
|
// --- vouchers ---
|
|
1187
1527
|
const vouchers = program.command('vouchers').description('Voucher operations');
|
|
@@ -1191,6 +1531,7 @@ vouchers
|
|
|
1191
1531
|
.option('--series <series>', 'Voucher series (e.g. "A")')
|
|
1192
1532
|
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
1193
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.')
|
|
1194
1535
|
.option('--year <number>', 'Financial year', parseInt)
|
|
1195
1536
|
.option('--page <number>', 'Page number', parseInt)
|
|
1196
1537
|
.option('--limit <number>', 'Results per page', parseInt)
|
|
@@ -1199,8 +1540,7 @@ vouchers
|
|
|
1199
1540
|
const { listVouchers } = await import('./operations/vouchers.js');
|
|
1200
1541
|
const data = await listVouchers({
|
|
1201
1542
|
series: opts.series,
|
|
1202
|
-
|
|
1203
|
-
toDate: opts.to,
|
|
1543
|
+
...fromToParams(opts),
|
|
1204
1544
|
financialYear: opts.year,
|
|
1205
1545
|
page: opts.page,
|
|
1206
1546
|
limit: opts.limit,
|
|
@@ -1217,7 +1557,7 @@ vouchers
|
|
|
1217
1557
|
const { getVoucher } = await import('./operations/vouchers.js');
|
|
1218
1558
|
const data = await getVoucher(series, voucherNumber, opts.year);
|
|
1219
1559
|
if (json()) {
|
|
1220
|
-
console.log(JSON.stringify(data, null, 2));
|
|
1560
|
+
console.log(JSON.stringify({ Voucher: data }, null, 2));
|
|
1221
1561
|
}
|
|
1222
1562
|
else {
|
|
1223
1563
|
outputDetail(data, voucherDetailColumns, false);
|
|
@@ -1247,7 +1587,35 @@ Examples:
|
|
|
1247
1587
|
return;
|
|
1248
1588
|
}
|
|
1249
1589
|
const data = await createVoucher(params);
|
|
1250
|
-
outputDetail(data, voucherDetailColumns, json());
|
|
1590
|
+
outputDetail(data, voucherDetailColumns, json(), 'Voucher');
|
|
1591
|
+
});
|
|
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
|
+
}
|
|
1251
1619
|
});
|
|
1252
1620
|
// --- invoice-payments ---
|
|
1253
1621
|
const invoicePayments = program
|
|
@@ -1278,7 +1646,7 @@ invoicePayments
|
|
|
1278
1646
|
.action(async (paymentNumber) => {
|
|
1279
1647
|
const { getInvoicePayment } = await import('./operations/invoice-payments.js');
|
|
1280
1648
|
const data = await getInvoicePayment(paymentNumber);
|
|
1281
|
-
outputDetail(data, invoicePaymentDetailColumns, json());
|
|
1649
|
+
outputDetail(data, invoicePaymentDetailColumns, json(), 'InvoicePayment');
|
|
1282
1650
|
});
|
|
1283
1651
|
invoicePayments
|
|
1284
1652
|
.command('create')
|
|
@@ -1305,7 +1673,7 @@ Examples:
|
|
|
1305
1673
|
return;
|
|
1306
1674
|
}
|
|
1307
1675
|
const data = await createInvoicePayment(params);
|
|
1308
|
-
outputDetail(data, invoicePaymentDetailColumns, json());
|
|
1676
|
+
outputDetail(data, invoicePaymentDetailColumns, json(), 'InvoicePayment');
|
|
1309
1677
|
});
|
|
1310
1678
|
invoicePayments
|
|
1311
1679
|
.command('delete <paymentNumber>')
|
|
@@ -1318,7 +1686,7 @@ invoicePayments
|
|
|
1318
1686
|
return;
|
|
1319
1687
|
}
|
|
1320
1688
|
await deleteInvoicePayment(paymentNumber);
|
|
1321
|
-
outputConfirmation(`Invoice payment ${paymentNumber} deleted.`, json(), {});
|
|
1689
|
+
outputConfirmation(`Invoice payment ${paymentNumber} deleted.`, json(), { Number: paymentNumber, deleted: true }, undefined, 'InvoicePayment');
|
|
1322
1690
|
});
|
|
1323
1691
|
invoicePayments
|
|
1324
1692
|
.command('bookkeep <paymentNumber>')
|
|
@@ -1331,7 +1699,7 @@ invoicePayments
|
|
|
1331
1699
|
return;
|
|
1332
1700
|
}
|
|
1333
1701
|
const result = await bookkeepInvoicePayment(paymentNumber);
|
|
1334
|
-
outputConfirmation(`Invoice payment ${paymentNumber} bookkeept.`, json(), result);
|
|
1702
|
+
outputConfirmation(`Invoice payment ${paymentNumber} bookkeept.`, json(), result, undefined, 'InvoicePayment');
|
|
1335
1703
|
});
|
|
1336
1704
|
// --- supplier-invoice-payments ---
|
|
1337
1705
|
const supplierInvoicePayments = program
|
|
@@ -1362,7 +1730,7 @@ supplierInvoicePayments
|
|
|
1362
1730
|
.action(async (paymentNumber) => {
|
|
1363
1731
|
const { getSupplierInvoicePayment } = await import('./operations/supplier-invoice-payments.js');
|
|
1364
1732
|
const data = await getSupplierInvoicePayment(paymentNumber);
|
|
1365
|
-
outputDetail(data, supplierInvoicePaymentDetailColumns, json());
|
|
1733
|
+
outputDetail(data, supplierInvoicePaymentDetailColumns, json(), 'SupplierInvoicePayment');
|
|
1366
1734
|
});
|
|
1367
1735
|
supplierInvoicePayments
|
|
1368
1736
|
.command('create')
|
|
@@ -1387,7 +1755,7 @@ Examples:
|
|
|
1387
1755
|
return;
|
|
1388
1756
|
}
|
|
1389
1757
|
const data = await createSupplierInvoicePayment(params);
|
|
1390
|
-
outputDetail(data, supplierInvoicePaymentDetailColumns, json());
|
|
1758
|
+
outputDetail(data, supplierInvoicePaymentDetailColumns, json(), 'SupplierInvoicePayment');
|
|
1391
1759
|
});
|
|
1392
1760
|
supplierInvoicePayments
|
|
1393
1761
|
.command('delete <paymentNumber>')
|
|
@@ -1400,7 +1768,7 @@ supplierInvoicePayments
|
|
|
1400
1768
|
return;
|
|
1401
1769
|
}
|
|
1402
1770
|
await deleteSupplierInvoicePayment(paymentNumber);
|
|
1403
|
-
outputConfirmation(`Supplier invoice payment ${paymentNumber} deleted.`, json(), {});
|
|
1771
|
+
outputConfirmation(`Supplier invoice payment ${paymentNumber} deleted.`, json(), { Number: paymentNumber, deleted: true }, undefined, 'SupplierInvoicePayment');
|
|
1404
1772
|
});
|
|
1405
1773
|
// --- offers ---
|
|
1406
1774
|
const offers = program.command('offers').description('Offer/quote operations (offerter)');
|
|
@@ -1411,6 +1779,7 @@ offers
|
|
|
1411
1779
|
.option('--customer <number>', 'Filter by customer number')
|
|
1412
1780
|
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
1413
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.')
|
|
1414
1783
|
.option('--page <number>', 'Page number', parseInt)
|
|
1415
1784
|
.option('--limit <number>', 'Results per page', parseInt)
|
|
1416
1785
|
.option('-a, --all', 'Fetch all pages')
|
|
@@ -1419,8 +1788,7 @@ offers
|
|
|
1419
1788
|
const data = await listOffers({
|
|
1420
1789
|
filter: opts.filter,
|
|
1421
1790
|
customerNumber: opts.customer,
|
|
1422
|
-
|
|
1423
|
-
toDate: opts.to,
|
|
1791
|
+
...fromToParams(opts),
|
|
1424
1792
|
page: opts.page,
|
|
1425
1793
|
limit: opts.limit,
|
|
1426
1794
|
all: opts.all,
|
|
@@ -1434,7 +1802,7 @@ offers
|
|
|
1434
1802
|
.action(async (documentNumber) => {
|
|
1435
1803
|
const { getOffer } = await import('./operations/offers.js');
|
|
1436
1804
|
const data = await getOffer(documentNumber);
|
|
1437
|
-
outputDetail(data, offerDetailColumns, json());
|
|
1805
|
+
outputDetail(data, offerDetailColumns, json(), 'Offer');
|
|
1438
1806
|
});
|
|
1439
1807
|
offers
|
|
1440
1808
|
.command('create')
|
|
@@ -1457,7 +1825,7 @@ Examples:
|
|
|
1457
1825
|
return;
|
|
1458
1826
|
}
|
|
1459
1827
|
const data = await createOffer(params);
|
|
1460
|
-
outputDetail(data, offerDetailColumns, json());
|
|
1828
|
+
outputDetail(data, offerDetailColumns, json(), 'Offer');
|
|
1461
1829
|
});
|
|
1462
1830
|
offers
|
|
1463
1831
|
.command('update <documentNumber>')
|
|
@@ -1473,7 +1841,7 @@ offers
|
|
|
1473
1841
|
return;
|
|
1474
1842
|
}
|
|
1475
1843
|
const data = await updateOffer(documentNumber, fields);
|
|
1476
|
-
outputDetail(data, offerDetailColumns, json());
|
|
1844
|
+
outputDetail(data, offerDetailColumns, json(), 'Offer');
|
|
1477
1845
|
});
|
|
1478
1846
|
offers
|
|
1479
1847
|
.command('create-invoice <documentNumber>')
|
|
@@ -1486,7 +1854,7 @@ offers
|
|
|
1486
1854
|
return;
|
|
1487
1855
|
}
|
|
1488
1856
|
const data = await createInvoiceFromOffer(documentNumber);
|
|
1489
|
-
outputConfirmation(`Invoice created from offer ${documentNumber}.`, json(), data, invoiceConfirmColumns);
|
|
1857
|
+
outputConfirmation(`Invoice created from offer ${documentNumber}.`, json(), data, invoiceConfirmColumns, 'Invoice');
|
|
1490
1858
|
});
|
|
1491
1859
|
offers
|
|
1492
1860
|
.command('create-order <documentNumber>')
|
|
@@ -1499,7 +1867,7 @@ offers
|
|
|
1499
1867
|
return;
|
|
1500
1868
|
}
|
|
1501
1869
|
const data = await createOrderFromOffer(documentNumber);
|
|
1502
|
-
outputConfirmation(`Order created from offer ${documentNumber}.`, json(), data);
|
|
1870
|
+
outputConfirmation(`Order created from offer ${documentNumber}.`, json(), data, undefined, 'Order');
|
|
1503
1871
|
});
|
|
1504
1872
|
// --- orders ---
|
|
1505
1873
|
const orders = program.command('orders').description('Order operations (ordrar)');
|
|
@@ -1510,6 +1878,7 @@ orders
|
|
|
1510
1878
|
.option('--customer <number>', 'Filter by customer number')
|
|
1511
1879
|
.option('--from <date>', 'From date (YYYY-MM-DD)')
|
|
1512
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.')
|
|
1513
1882
|
.option('--page <number>', 'Page number', parseInt)
|
|
1514
1883
|
.option('--limit <number>', 'Results per page', parseInt)
|
|
1515
1884
|
.option('-a, --all', 'Fetch all pages')
|
|
@@ -1518,8 +1887,7 @@ orders
|
|
|
1518
1887
|
const data = await listOrders({
|
|
1519
1888
|
filter: opts.filter,
|
|
1520
1889
|
customerNumber: opts.customer,
|
|
1521
|
-
|
|
1522
|
-
toDate: opts.to,
|
|
1890
|
+
...fromToParams(opts),
|
|
1523
1891
|
page: opts.page,
|
|
1524
1892
|
limit: opts.limit,
|
|
1525
1893
|
all: opts.all,
|
|
@@ -1533,7 +1901,7 @@ orders
|
|
|
1533
1901
|
.action(async (documentNumber) => {
|
|
1534
1902
|
const { getOrder } = await import('./operations/orders.js');
|
|
1535
1903
|
const data = await getOrder(documentNumber);
|
|
1536
|
-
outputDetail(data, orderDetailColumns, json());
|
|
1904
|
+
outputDetail(data, orderDetailColumns, json(), 'Order');
|
|
1537
1905
|
});
|
|
1538
1906
|
orders
|
|
1539
1907
|
.command('create')
|
|
@@ -1556,7 +1924,7 @@ Examples:
|
|
|
1556
1924
|
return;
|
|
1557
1925
|
}
|
|
1558
1926
|
const data = await createOrder(params);
|
|
1559
|
-
outputDetail(data, orderDetailColumns, json());
|
|
1927
|
+
outputDetail(data, orderDetailColumns, json(), 'Order');
|
|
1560
1928
|
});
|
|
1561
1929
|
orders
|
|
1562
1930
|
.command('update <documentNumber>')
|
|
@@ -1572,7 +1940,7 @@ orders
|
|
|
1572
1940
|
return;
|
|
1573
1941
|
}
|
|
1574
1942
|
const data = await updateOrder(documentNumber, fields);
|
|
1575
|
-
outputDetail(data, orderDetailColumns, json());
|
|
1943
|
+
outputDetail(data, orderDetailColumns, json(), 'Order');
|
|
1576
1944
|
});
|
|
1577
1945
|
orders
|
|
1578
1946
|
.command('create-invoice <documentNumber>')
|
|
@@ -1585,7 +1953,7 @@ orders
|
|
|
1585
1953
|
return;
|
|
1586
1954
|
}
|
|
1587
1955
|
const data = await createInvoiceFromOrder(documentNumber);
|
|
1588
|
-
outputConfirmation(`Invoice created from order ${documentNumber}.`, json(), data, invoiceConfirmColumns);
|
|
1956
|
+
outputConfirmation(`Invoice created from order ${documentNumber}.`, json(), data, invoiceConfirmColumns, 'Invoice');
|
|
1589
1957
|
});
|
|
1590
1958
|
// --- projects ---
|
|
1591
1959
|
const projects = program.command('projects').description('Project operations');
|
|
@@ -1611,7 +1979,7 @@ projects
|
|
|
1611
1979
|
.action(async (projectNumber) => {
|
|
1612
1980
|
const { getProject } = await import('./operations/projects.js');
|
|
1613
1981
|
const data = await getProject(projectNumber);
|
|
1614
|
-
outputDetail(data, projectDetailColumns, json());
|
|
1982
|
+
outputDetail(data, projectDetailColumns, json(), 'Project');
|
|
1615
1983
|
});
|
|
1616
1984
|
projects
|
|
1617
1985
|
.command('create')
|
|
@@ -1638,7 +2006,7 @@ projects
|
|
|
1638
2006
|
return;
|
|
1639
2007
|
}
|
|
1640
2008
|
const data = await createProject(params);
|
|
1641
|
-
outputDetail(data, projectDetailColumns, json());
|
|
2009
|
+
outputDetail(data, projectDetailColumns, json(), 'Project');
|
|
1642
2010
|
});
|
|
1643
2011
|
projects
|
|
1644
2012
|
.command('update <projectNumber>')
|
|
@@ -1654,7 +2022,7 @@ projects
|
|
|
1654
2022
|
return;
|
|
1655
2023
|
}
|
|
1656
2024
|
const data = await updateProject(projectNumber, fields);
|
|
1657
|
-
outputDetail(data, projectDetailColumns, json());
|
|
2025
|
+
outputDetail(data, projectDetailColumns, json(), 'Project');
|
|
1658
2026
|
});
|
|
1659
2027
|
// --- cost centers ---
|
|
1660
2028
|
const costcenters = program.command('costcenters').description('Cost center operations');
|
|
@@ -1680,7 +2048,7 @@ costcenters
|
|
|
1680
2048
|
.action(async (code) => {
|
|
1681
2049
|
const { getCostCenter } = await import('./operations/costcenters.js');
|
|
1682
2050
|
const data = await getCostCenter(code);
|
|
1683
|
-
outputDetail(data, costCenterDetailColumns, json());
|
|
2051
|
+
outputDetail(data, costCenterDetailColumns, json(), 'CostCenter');
|
|
1684
2052
|
});
|
|
1685
2053
|
costcenters
|
|
1686
2054
|
.command('create')
|
|
@@ -1706,7 +2074,7 @@ costcenters
|
|
|
1706
2074
|
return;
|
|
1707
2075
|
}
|
|
1708
2076
|
const data = await createCostCenter(params);
|
|
1709
|
-
outputDetail(data, costCenterDetailColumns, json());
|
|
2077
|
+
outputDetail(data, costCenterDetailColumns, json(), 'CostCenter');
|
|
1710
2078
|
});
|
|
1711
2079
|
costcenters
|
|
1712
2080
|
.command('update <code>')
|
|
@@ -1722,7 +2090,7 @@ costcenters
|
|
|
1722
2090
|
return;
|
|
1723
2091
|
}
|
|
1724
2092
|
const data = await updateCostCenter(code, fields);
|
|
1725
|
-
outputDetail(data, costCenterDetailColumns, json());
|
|
2093
|
+
outputDetail(data, costCenterDetailColumns, json(), 'CostCenter');
|
|
1726
2094
|
});
|
|
1727
2095
|
costcenters
|
|
1728
2096
|
.command('delete <code>')
|
|
@@ -1735,7 +2103,7 @@ costcenters
|
|
|
1735
2103
|
return;
|
|
1736
2104
|
}
|
|
1737
2105
|
await deleteCostCenter(code);
|
|
1738
|
-
|
|
2106
|
+
outputConfirmation(`Cost center ${code} deleted.`, json(), { Code: code, deleted: true }, undefined, 'CostCenter');
|
|
1739
2107
|
});
|
|
1740
2108
|
// --- tax reductions (ROT/RUT) ---
|
|
1741
2109
|
const taxreductions = program
|
|
@@ -1765,7 +2133,7 @@ taxreductions
|
|
|
1765
2133
|
.action(async (id) => {
|
|
1766
2134
|
const { getTaxReduction } = await import('./operations/taxreductions.js');
|
|
1767
2135
|
const data = await getTaxReduction(parseInt(id, 10));
|
|
1768
|
-
outputDetail(data, taxReductionDetailColumns, json());
|
|
2136
|
+
outputDetail(data, taxReductionDetailColumns, json(), 'TaxReduction');
|
|
1769
2137
|
});
|
|
1770
2138
|
taxreductions
|
|
1771
2139
|
.command('create')
|
|
@@ -1800,7 +2168,7 @@ taxreductions
|
|
|
1800
2168
|
return;
|
|
1801
2169
|
}
|
|
1802
2170
|
const data = await createTaxReduction(params);
|
|
1803
|
-
outputDetail(data, taxReductionDetailColumns, json());
|
|
2171
|
+
outputDetail(data, taxReductionDetailColumns, json(), 'TaxReduction');
|
|
1804
2172
|
});
|
|
1805
2173
|
// --- price lists ---
|
|
1806
2174
|
const pricelists = program.command('pricelists').description('Price list operations');
|
|
@@ -1826,7 +2194,7 @@ pricelists
|
|
|
1826
2194
|
.action(async (code) => {
|
|
1827
2195
|
const { getPriceList } = await import('./operations/pricelists.js');
|
|
1828
2196
|
const data = await getPriceList(code);
|
|
1829
|
-
outputDetail(data, priceListDetailColumns, json());
|
|
2197
|
+
outputDetail(data, priceListDetailColumns, json(), 'PriceList');
|
|
1830
2198
|
});
|
|
1831
2199
|
pricelists
|
|
1832
2200
|
.command('create')
|
|
@@ -1852,7 +2220,7 @@ pricelists
|
|
|
1852
2220
|
return;
|
|
1853
2221
|
}
|
|
1854
2222
|
const data = await createPriceList(params);
|
|
1855
|
-
outputDetail(data, priceListDetailColumns, json());
|
|
2223
|
+
outputDetail(data, priceListDetailColumns, json(), 'PriceList');
|
|
1856
2224
|
});
|
|
1857
2225
|
pricelists
|
|
1858
2226
|
.command('update <code>')
|
|
@@ -1868,7 +2236,7 @@ pricelists
|
|
|
1868
2236
|
return;
|
|
1869
2237
|
}
|
|
1870
2238
|
const data = await updatePriceList(code, fields);
|
|
1871
|
-
outputDetail(data, priceListDetailColumns, json());
|
|
2239
|
+
outputDetail(data, priceListDetailColumns, json(), 'PriceList');
|
|
1872
2240
|
});
|
|
1873
2241
|
// --- prices ---
|
|
1874
2242
|
const prices = program.command('prices').description('Price operations within price lists');
|
|
@@ -1899,7 +2267,7 @@ prices
|
|
|
1899
2267
|
.action(async (opts) => {
|
|
1900
2268
|
const { getPrice } = await import('./operations/pricelists.js');
|
|
1901
2269
|
const data = await getPrice(opts.pricelist, opts.article, opts.fromQuantity);
|
|
1902
|
-
outputDetail(data, priceDetailColumns, json());
|
|
2270
|
+
outputDetail(data, priceDetailColumns, json(), 'Price');
|
|
1903
2271
|
});
|
|
1904
2272
|
prices
|
|
1905
2273
|
.command('update')
|
|
@@ -1920,25 +2288,295 @@ prices
|
|
|
1920
2288
|
return;
|
|
1921
2289
|
}
|
|
1922
2290
|
const data = await updatePrice(opts.pricelist, opts.article, fields, opts.fromQuantity);
|
|
1923
|
-
outputDetail(data, priceDetailColumns, json());
|
|
2291
|
+
outputDetail(data, priceDetailColumns, json(), 'Price');
|
|
1924
2292
|
});
|
|
1925
|
-
//
|
|
1926
|
-
program
|
|
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).
|
|
1927
2557
|
try {
|
|
1928
2558
|
await program.parseAsync(process.argv);
|
|
1929
2559
|
}
|
|
1930
2560
|
catch (err) {
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
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);
|
|
1940
2579
|
}
|
|
1941
|
-
console.error(err instanceof Error ? err.message : err);
|
|
1942
2580
|
process.exit(1);
|
|
1943
2581
|
}
|
|
1944
2582
|
//# sourceMappingURL=cli.js.map
|