@yinzuoweia/nis 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lock.js ADDED
@@ -0,0 +1,47 @@
1
+ import { open, rm, stat } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { mkdir } from 'node:fs/promises';
4
+ import { CliError, isNodeErrorWithCode } from './errors.js';
5
+ function sleep(ms) {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
8
+ async function removeIfStale(lockPath, staleMs) {
9
+ try {
10
+ const info = await stat(lockPath);
11
+ if (Date.now() - info.mtimeMs > staleMs) {
12
+ await rm(lockPath, { force: true });
13
+ }
14
+ }
15
+ catch {
16
+ // ignore
17
+ }
18
+ }
19
+ export async function withFileLock(lockPath, fn, options = {}) {
20
+ const timeoutMs = options.timeoutMs ?? 5000;
21
+ const retryMs = options.retryMs ?? 80;
22
+ const staleMs = options.staleMs ?? 30_000;
23
+ await mkdir(dirname(lockPath), { recursive: true });
24
+ const started = Date.now();
25
+ while (true) {
26
+ try {
27
+ const handle = await open(lockPath, 'wx');
28
+ try {
29
+ return await fn();
30
+ }
31
+ finally {
32
+ await handle.close();
33
+ await rm(lockPath, { force: true });
34
+ }
35
+ }
36
+ catch (err) {
37
+ if (!isNodeErrorWithCode(err) || err.code !== 'EEXIST') {
38
+ throw err;
39
+ }
40
+ await removeIfStale(lockPath, staleMs);
41
+ if (Date.now() - started > timeoutMs) {
42
+ throw new CliError('LOCK_TIMEOUT', `failed to acquire lock within ${timeoutMs}ms`, `lockPath=${lockPath}`);
43
+ }
44
+ await sleep(retryMs);
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,4 @@
1
+ import type { ParsedQuery, TreeNode } from './types.js';
2
+ export declare function parseDurationToMs(raw: string): number;
3
+ export declare function parseQuery(query: string): ParsedQuery;
4
+ export declare function matchesNode(node: TreeNode, parsed: ParsedQuery, now?: Date): boolean;
package/dist/query.js ADDED
@@ -0,0 +1,166 @@
1
+ const COMPARE_RE = /^([^:<>!=\s]+)(>=|<=|>|<)(.+)$/;
2
+ const FIELD_RE = /^([^:<>!=\s]+):(.+)$/;
3
+ function tokenize(query) {
4
+ return query.match(/"[^"]+"|'[^']+'|\S+/g) ?? [];
5
+ }
6
+ function stripQuotes(raw) {
7
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
8
+ return raw.slice(1, -1);
9
+ }
10
+ return raw;
11
+ }
12
+ export function parseDurationToMs(raw) {
13
+ const match = raw.trim().match(/^(\d+)([smhdw])$/i);
14
+ if (!match) {
15
+ return Number.NaN;
16
+ }
17
+ const value = Number(match[1]);
18
+ const unit = match[2].toLowerCase();
19
+ const map = {
20
+ s: 1000,
21
+ m: 60_000,
22
+ h: 3_600_000,
23
+ d: 86_400_000,
24
+ w: 604_800_000
25
+ };
26
+ return value * map[unit];
27
+ }
28
+ export function parseQuery(query) {
29
+ const terms = [];
30
+ const filters = [];
31
+ for (const tokenRaw of tokenize(query)) {
32
+ const token = stripQuotes(tokenRaw);
33
+ const lower = token.toLowerCase();
34
+ if (lower.startsWith('newer_than:')) {
35
+ const ms = parseDurationToMs(token.slice('newer_than:'.length));
36
+ if (!Number.isNaN(ms)) {
37
+ filters.push({ kind: 'relative', key: 'updated_at', direction: 'newer', ms });
38
+ continue;
39
+ }
40
+ }
41
+ if (lower.startsWith('older_than:')) {
42
+ const ms = parseDurationToMs(token.slice('older_than:'.length));
43
+ if (!Number.isNaN(ms)) {
44
+ filters.push({ kind: 'relative', key: 'updated_at', direction: 'older', ms });
45
+ continue;
46
+ }
47
+ }
48
+ const compare = token.match(COMPARE_RE);
49
+ if (compare) {
50
+ filters.push({
51
+ kind: 'compare',
52
+ key: compare[1],
53
+ op: compare[2],
54
+ value: compare[3]
55
+ });
56
+ continue;
57
+ }
58
+ const field = token.match(FIELD_RE);
59
+ if (field) {
60
+ filters.push({ kind: 'field', key: field[1], value: field[2] });
61
+ continue;
62
+ }
63
+ terms.push(token);
64
+ }
65
+ return { terms, filters };
66
+ }
67
+ function toComparable(value) {
68
+ if (typeof value === 'number') {
69
+ return value;
70
+ }
71
+ if (typeof value === 'string') {
72
+ const date = Date.parse(value);
73
+ if (!Number.isNaN(date)) {
74
+ return date;
75
+ }
76
+ const num = Number(value);
77
+ if (!Number.isNaN(num)) {
78
+ return num;
79
+ }
80
+ return value.toLowerCase();
81
+ }
82
+ if (value instanceof Date) {
83
+ return value.getTime();
84
+ }
85
+ return String(value).toLowerCase();
86
+ }
87
+ function compareValues(left, rightRaw, op) {
88
+ const leftComp = toComparable(left);
89
+ const rightComp = toComparable(rightRaw);
90
+ if (typeof leftComp === 'number' && typeof rightComp === 'number') {
91
+ switch (op) {
92
+ case '>':
93
+ return leftComp > rightComp;
94
+ case '>=':
95
+ return leftComp >= rightComp;
96
+ case '<':
97
+ return leftComp < rightComp;
98
+ case '<=':
99
+ return leftComp <= rightComp;
100
+ default:
101
+ return false;
102
+ }
103
+ }
104
+ const leftStr = String(leftComp);
105
+ const rightStr = String(rightComp);
106
+ switch (op) {
107
+ case '>':
108
+ return leftStr > rightStr;
109
+ case '>=':
110
+ return leftStr >= rightStr;
111
+ case '<':
112
+ return leftStr < rightStr;
113
+ case '<=':
114
+ return leftStr <= rightStr;
115
+ default:
116
+ return false;
117
+ }
118
+ }
119
+ export function matchesNode(node, parsed, now = new Date()) {
120
+ const blob = Object.values(node)
121
+ .map((v) => (typeof v === 'string' ? v : ''))
122
+ .join(' ')
123
+ .toLowerCase();
124
+ for (const term of parsed.terms) {
125
+ if (!blob.includes(term.toLowerCase())) {
126
+ return false;
127
+ }
128
+ }
129
+ for (const filter of parsed.filters) {
130
+ if (filter.kind === 'field') {
131
+ const value = node[filter.key];
132
+ if (value === undefined || value === null) {
133
+ return false;
134
+ }
135
+ if (Array.isArray(value)) {
136
+ const found = value.some((item) => String(item).toLowerCase().includes(filter.value.toLowerCase()));
137
+ if (!found) {
138
+ return false;
139
+ }
140
+ }
141
+ else if (!String(value).toLowerCase().includes(filter.value.toLowerCase())) {
142
+ return false;
143
+ }
144
+ continue;
145
+ }
146
+ if (filter.kind === 'compare') {
147
+ const value = node[filter.key];
148
+ if (value === undefined || !compareValues(value, filter.value, filter.op)) {
149
+ return false;
150
+ }
151
+ continue;
152
+ }
153
+ const updated = Date.parse(String(node.updated_at));
154
+ if (Number.isNaN(updated)) {
155
+ return false;
156
+ }
157
+ const threshold = now.getTime() - filter.ms;
158
+ if (filter.direction === 'newer' && !(updated >= threshold)) {
159
+ return false;
160
+ }
161
+ if (filter.direction === 'older' && !(updated <= threshold)) {
162
+ return false;
163
+ }
164
+ }
165
+ return true;
166
+ }
@@ -0,0 +1,12 @@
1
+ import { type TreeFile } from './types.js';
2
+ export type ResolvePathOptions = {
3
+ filePath?: string;
4
+ };
5
+ export declare function resolveTreePath(options?: ResolvePathOptions): string;
6
+ export declare function getLockPath(filePath: string): string;
7
+ export declare function snapshotDir(filePath: string): string;
8
+ export declare function createInitialTree(now?: Date): TreeFile;
9
+ export declare function readTree(filePath: string): Promise<TreeFile>;
10
+ export declare function writeTreeAtomic(filePath: string, tree: TreeFile): Promise<void>;
11
+ export declare function fileExists(filePath: string): Promise<boolean>;
12
+ export declare function removeFileIfExists(filePath: string): Promise<void>;
@@ -0,0 +1,96 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { CliError, isNodeErrorWithCode } from './errors.js';
5
+ import { ROOT_ID } from './types.js';
6
+ const nodeSchema = z
7
+ .object({
8
+ id: z.string().min(1),
9
+ parent: z.string().nullable(),
10
+ children: z.array(z.string()),
11
+ created_at: z.string(),
12
+ updated_at: z.string()
13
+ })
14
+ .passthrough();
15
+ const treeSchema = z.object({
16
+ version: z.string().min(1),
17
+ root_id: z.literal(ROOT_ID),
18
+ nodes: z.record(nodeSchema)
19
+ });
20
+ export function resolveTreePath(options = {}) {
21
+ if (options.filePath) {
22
+ return resolve(options.filePath);
23
+ }
24
+ return resolve('.nis/tree.json');
25
+ }
26
+ export function getLockPath(filePath) {
27
+ return `${filePath}.lock`;
28
+ }
29
+ export function snapshotDir(filePath) {
30
+ return join(dirname(filePath), 'snapshots');
31
+ }
32
+ export function createInitialTree(now = new Date()) {
33
+ const ts = now.toISOString();
34
+ return {
35
+ version: '1.0',
36
+ root_id: ROOT_ID,
37
+ nodes: {
38
+ [ROOT_ID]: {
39
+ id: ROOT_ID,
40
+ parent: null,
41
+ children: [],
42
+ created_at: ts,
43
+ updated_at: ts
44
+ }
45
+ }
46
+ };
47
+ }
48
+ export async function readTree(filePath) {
49
+ try {
50
+ const content = await readFile(filePath, 'utf-8');
51
+ const parsed = JSON.parse(content);
52
+ const validated = treeSchema.safeParse(parsed);
53
+ if (!validated.success) {
54
+ throw new CliError('SCHEMA_INVALID', 'tree file schema is invalid', validated.error.message);
55
+ }
56
+ return validated.data;
57
+ }
58
+ catch (err) {
59
+ if (isNodeErrorWithCode(err) && err.code === 'ENOENT') {
60
+ throw new CliError('FILE_NOT_FOUND', `tree file not found: ${filePath}`, 'run `nis init` first');
61
+ }
62
+ if (err instanceof CliError) {
63
+ throw err;
64
+ }
65
+ if (err instanceof SyntaxError) {
66
+ throw new CliError('SCHEMA_INVALID', 'tree file is not valid JSON', err.message);
67
+ }
68
+ throw err;
69
+ }
70
+ }
71
+ export async function writeTreeAtomic(filePath, tree) {
72
+ const validated = treeSchema.safeParse(tree);
73
+ if (!validated.success) {
74
+ throw new CliError('SCHEMA_INVALID', 'tree object is invalid', validated.error.message);
75
+ }
76
+ const dir = dirname(filePath);
77
+ await mkdir(dir, { recursive: true });
78
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
79
+ await writeFile(tempPath, JSON.stringify(validated.data, null, 2), 'utf-8');
80
+ await rename(tempPath, filePath);
81
+ }
82
+ export async function fileExists(filePath) {
83
+ try {
84
+ await readFile(filePath);
85
+ return true;
86
+ }
87
+ catch (err) {
88
+ if (isNodeErrorWithCode(err) && err.code === 'ENOENT') {
89
+ return false;
90
+ }
91
+ throw err;
92
+ }
93
+ }
94
+ export async function removeFileIfExists(filePath) {
95
+ await rm(filePath, { force: true });
96
+ }
@@ -0,0 +1,52 @@
1
+ export declare const ROOT_ID = "root";
2
+ export declare const RESERVED_FIELDS: Set<string>;
3
+ export type ErrorCode = 'FILE_NOT_FOUND' | 'SCHEMA_INVALID' | 'NODE_NOT_FOUND' | 'NODE_ID_CONFLICT' | 'ROOT_IMMUTABLE' | 'DELETE_CONFIRM_REQUIRED' | 'CYCLE_DETECTED' | 'LOCK_TIMEOUT';
4
+ export type TreeNode = {
5
+ id: string;
6
+ parent: string | null;
7
+ children: string[];
8
+ created_at: string;
9
+ updated_at: string;
10
+ [key: string]: unknown;
11
+ };
12
+ export type TreeFile = {
13
+ version: string;
14
+ root_id: typeof ROOT_ID;
15
+ nodes: Record<string, TreeNode>;
16
+ };
17
+ export type QueryComparator = '>' | '>=' | '<' | '<=';
18
+ export type QueryFilter = {
19
+ kind: 'field';
20
+ key: string;
21
+ value: string;
22
+ } | {
23
+ kind: 'compare';
24
+ key: string;
25
+ op: QueryComparator;
26
+ value: string;
27
+ } | {
28
+ kind: 'relative';
29
+ key: 'updated_at';
30
+ direction: 'newer' | 'older';
31
+ ms: number;
32
+ };
33
+ export type ParsedQuery = {
34
+ terms: string[];
35
+ filters: QueryFilter[];
36
+ };
37
+ export type SuccessEnvelope<T> = {
38
+ ok: true;
39
+ action: string;
40
+ file: string;
41
+ result: T;
42
+ warnings: string[];
43
+ };
44
+ export type ErrorEnvelope = {
45
+ ok: false;
46
+ action: string;
47
+ error: {
48
+ code: ErrorCode | 'UNKNOWN';
49
+ message: string;
50
+ hint?: string;
51
+ };
52
+ };
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ export const ROOT_ID = 'root';
2
+ export const RESERVED_FIELDS = new Set([
3
+ 'id',
4
+ 'parent',
5
+ 'children',
6
+ 'created_at',
7
+ 'updated_at'
8
+ ]);
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@yinzuoweia/nis",
3
+ "version": "0.1.0",
4
+ "description": "NIS CLI for agent-friendly JSON tree operations",
5
+ "type": "module",
6
+ "bin": {
7
+ "nis": "dist/cli.js"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "prepack": "npm run build",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest",
19
+ "dev": "tsx src/cli.ts"
20
+ },
21
+ "dependencies": {
22
+ "commander": "^13.1.0",
23
+ "zod": "^3.25.76"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^24.3.0",
27
+ "execa": "^9.6.0",
28
+ "tsx": "^4.20.5",
29
+ "typescript": "^5.9.2",
30
+ "vitest": "^3.2.4"
31
+ }
32
+ }