newo 3.6.2 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +44 -3
  2. package/README.md +61 -0
  3. package/dist/cli/commands/check.d.ts +3 -0
  4. package/dist/cli/commands/check.js +15 -0
  5. package/dist/cli/commands/format.d.ts +3 -0
  6. package/dist/cli/commands/format.js +105 -0
  7. package/dist/cli/commands/help.js +13 -0
  8. package/dist/cli/commands/lint.d.ts +3 -0
  9. package/dist/cli/commands/lint.js +195 -0
  10. package/dist/cli-new/di/tokens.d.ts +1 -1
  11. package/dist/cli.js +45 -9
  12. package/dist/domain/strategies/sync/AttributeSyncStrategy.js +38 -8
  13. package/dist/lint/config.d.ts +4 -0
  14. package/dist/lint/config.js +14 -0
  15. package/dist/lint/discovery.d.ts +34 -0
  16. package/dist/lint/discovery.js +112 -0
  17. package/dist/lint/live-schema.d.ts +20 -0
  18. package/dist/lint/live-schema.js +52 -0
  19. package/dist/lint/reporters/index.d.ts +4 -0
  20. package/dist/lint/reporters/index.js +19 -0
  21. package/dist/lint/reporters/json.d.ts +3 -0
  22. package/dist/lint/reporters/json.js +6 -0
  23. package/dist/lint/reporters/sarif.d.ts +3 -0
  24. package/dist/lint/reporters/sarif.js +47 -0
  25. package/dist/lint/reporters/text.d.ts +3 -0
  26. package/dist/lint/reporters/text.js +51 -0
  27. package/dist/lint/reporters/types.d.ts +6 -0
  28. package/dist/lint/reporters/types.js +2 -0
  29. package/dist/sync/attributes.js +38 -12
  30. package/dist/sync/conversations.d.ts +1 -1
  31. package/dist/sync/conversations.js +240 -193
  32. package/dist/sync/json-attr-utils.d.ts +67 -0
  33. package/dist/sync/json-attr-utils.js +98 -0
  34. package/package.json +3 -1
  35. package/src/cli/commands/check.ts +21 -0
  36. package/src/cli/commands/format.ts +131 -0
  37. package/src/cli/commands/help.ts +13 -0
  38. package/src/cli/commands/lint.ts +246 -0
  39. package/src/cli.ts +50 -9
  40. package/src/domain/strategies/sync/AttributeSyncStrategy.ts +45 -8
  41. package/src/lint/config.ts +17 -0
  42. package/src/lint/discovery.ts +148 -0
  43. package/src/lint/live-schema.ts +62 -0
  44. package/src/lint/reporters/index.ts +22 -0
  45. package/src/lint/reporters/json.ts +12 -0
  46. package/src/lint/reporters/sarif.ts +59 -0
  47. package/src/lint/reporters/text.ts +58 -0
  48. package/src/lint/reporters/types.ts +7 -0
  49. package/src/sync/attributes.ts +43 -14
  50. package/src/sync/conversations.ts +265 -212
  51. package/src/sync/json-attr-utils.ts +95 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * File discovery for `newo lint` / `newo format` / `newo check`.
3
+ *
4
+ * Walks a customer's tree (or any directory passed on the CLI), filters
5
+ * by format-aware extensions, and optionally narrows to files changed
6
+ * since the last push by consulting `.newo/{customer}/hashes.json`.
7
+ */
8
+ import fs from 'fs-extra';
9
+ import path from 'path';
10
+ import { NEWO_CUSTOMERS_DIR, customerDir } from '../fsutil.js';
11
+ import { loadHashes } from '../hash.js';
12
+ import { sha256 } from '../hash.js';
13
+ import {
14
+ ALL_SCRIPT_EXTENSIONS,
15
+ type FormatVersion,
16
+ CLI_V1_EXTENSIONS,
17
+ NEWO_V2_EXTENSIONS,
18
+ } from '../format/types.js';
19
+ import type { CustomerConfig } from '../types.js';
20
+
21
+ export interface DiscoveryOptions {
22
+ /** If set, restrict to one format's extensions only. */
23
+ format?: FormatVersion;
24
+ /** If true, include only files that hash-differ from `.newo/{customer}/hashes.json`. */
25
+ changedOnly?: boolean;
26
+ /** Additional absolute paths to skip. */
27
+ ignore?: string[];
28
+ }
29
+
30
+ export interface DiscoveredFile {
31
+ /** Absolute path on disk. */
32
+ absPath: string;
33
+ /** Path relative to the customer root (for display / reports). */
34
+ relPath: string;
35
+ /** Extension (including leading dot). */
36
+ ext: string;
37
+ }
38
+
39
+ /**
40
+ * Discover script files under a customer's tree.
41
+ * Respects format when given (else walks all recognized extensions).
42
+ */
43
+ export async function discoverCustomerFiles(
44
+ customer: CustomerConfig,
45
+ opts: DiscoveryOptions = {},
46
+ ): Promise<DiscoveredFile[]> {
47
+ const root = customerDir(customer.idn);
48
+ if (!(await fs.pathExists(root))) return [];
49
+
50
+ const exts = pickExtensions(opts.format);
51
+ const ignoreSet = new Set(opts.ignore ?? []);
52
+
53
+ const hits = await walkForExtensions(root, exts, ignoreSet);
54
+
55
+ if (!opts.changedOnly) {
56
+ return hits.map(absPath => toDiscoveredFile(absPath, root));
57
+ }
58
+
59
+ const stored = await loadHashes(customer.idn);
60
+ const changed: DiscoveredFile[] = [];
61
+ for (const absPath of hits) {
62
+ const current = sha256(await fs.readFile(absPath, 'utf8'));
63
+ if (stored[absPath] !== current) {
64
+ changed.push(toDiscoveredFile(absPath, root));
65
+ }
66
+ }
67
+ return changed;
68
+ }
69
+
70
+ /**
71
+ * Discover files under an arbitrary directory.
72
+ * Used when the user passes explicit paths to `newo lint some/dir`.
73
+ */
74
+ export async function discoverFromPath(
75
+ inputPath: string,
76
+ opts: DiscoveryOptions = {},
77
+ ): Promise<DiscoveredFile[]> {
78
+ const abs = path.resolve(inputPath);
79
+ if (!(await fs.pathExists(abs))) return [];
80
+
81
+ const exts = pickExtensions(opts.format);
82
+ const ignoreSet = new Set(opts.ignore ?? []);
83
+ const stat = await fs.stat(abs);
84
+
85
+ let hits: string[];
86
+ if (stat.isFile()) {
87
+ hits = exts.includes(path.extname(abs)) ? [abs] : [];
88
+ } else {
89
+ hits = await walkForExtensions(abs, exts, ignoreSet);
90
+ }
91
+
92
+ const root = stat.isDirectory() ? abs : path.dirname(abs);
93
+ return hits.map(p => toDiscoveredFile(p, root));
94
+ }
95
+
96
+ function pickExtensions(format?: FormatVersion): string[] {
97
+ if (!format) return [...ALL_SCRIPT_EXTENSIONS];
98
+ const map = format === 'newo_v2' ? NEWO_V2_EXTENSIONS : CLI_V1_EXTENSIONS;
99
+ return Object.values(map);
100
+ }
101
+
102
+ function toDiscoveredFile(absPath: string, root: string): DiscoveredFile {
103
+ return {
104
+ absPath,
105
+ relPath: path.relative(root, absPath),
106
+ ext: path.extname(absPath),
107
+ };
108
+ }
109
+
110
+ async function walkForExtensions(
111
+ dir: string,
112
+ exts: string[],
113
+ ignore: Set<string>,
114
+ ): Promise<string[]> {
115
+ const out: string[] = [];
116
+ const stack: string[] = [dir];
117
+ while (stack.length > 0) {
118
+ const current = stack.pop()!;
119
+ if (ignore.has(current)) continue;
120
+ let entries: fs.Dirent[];
121
+ try {
122
+ entries = await fs.readdir(current, { withFileTypes: true });
123
+ } catch {
124
+ continue;
125
+ }
126
+ for (const entry of entries) {
127
+ // Skip hidden dirs, node_modules, and the .newo state directory.
128
+ if (entry.name.startsWith('.')) continue;
129
+ if (entry.name === 'node_modules') continue;
130
+ const full = path.join(current, entry.name);
131
+ if (ignore.has(full)) continue;
132
+ if (entry.isDirectory()) {
133
+ stack.push(full);
134
+ } else if (exts.includes(path.extname(entry.name))) {
135
+ out.push(full);
136
+ }
137
+ }
138
+ }
139
+ return out.sort();
140
+ }
141
+
142
+ /**
143
+ * Default root for lint invocations with no path arguments - the
144
+ * `newo_customers/` directory at the cwd.
145
+ */
146
+ export function defaultRoot(): string {
147
+ return NEWO_CUSTOMERS_DIR;
148
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Live schema refresh: hits `/api/v1/script/actions` via the existing
3
+ * NEWO api client, caches the response to `.newo/{customer}/actions.json`,
4
+ * and returns an object shaped for `createLinter({ schemas: { kind: 'inline', ... }})`.
5
+ */
6
+ import fs from 'fs-extra';
7
+ import path from 'path';
8
+ import { customerStateDir } from '../fsutil.js';
9
+ import type { CustomerConfig, ScriptAction } from '../types.js';
10
+ import { getValidAccessToken } from '../auth.js';
11
+ import { makeClient, getScriptActions } from '../api.js';
12
+
13
+ export interface LiveSchemaSnapshot {
14
+ actions: Array<{ name: string; [k: string]: unknown }>;
15
+ fetchedAt: string;
16
+ }
17
+
18
+ export async function liveSchemaCachePath(customerIdn: string): Promise<string> {
19
+ const dir = customerStateDir(customerIdn);
20
+ await fs.ensureDir(dir);
21
+ return path.join(dir, 'actions.json');
22
+ }
23
+
24
+ /**
25
+ * Fetch the current action catalog from NEWO and cache it.
26
+ * Returns a snapshot ready to pass to `createLinter`.
27
+ */
28
+ export async function refreshLiveSchema(
29
+ customer: CustomerConfig,
30
+ ): Promise<LiveSchemaSnapshot> {
31
+ const token = await getValidAccessToken(customer);
32
+ const client = await makeClient(false, token);
33
+ const actions = await getScriptActions(client);
34
+ const snapshot: LiveSchemaSnapshot = {
35
+ actions: actions.map((a: ScriptAction) => ({
36
+ name: a.idn ?? a.title,
37
+ title: a.title,
38
+ ...(a.idn !== undefined ? { idn: a.idn } : {}),
39
+ arguments: a.arguments,
40
+ })),
41
+ fetchedAt: new Date().toISOString(),
42
+ };
43
+ const cachePath = await liveSchemaCachePath(customer.idn);
44
+ await fs.writeJson(cachePath, snapshot, { spaces: 2 });
45
+ return snapshot;
46
+ }
47
+
48
+ /**
49
+ * Load the cached snapshot if present. Returns null when the cache is
50
+ * missing or corrupt (caller should fall back to bundled schemas).
51
+ */
52
+ export async function loadCachedLiveSchema(
53
+ customerIdn: string,
54
+ ): Promise<LiveSchemaSnapshot | null> {
55
+ const cachePath = await liveSchemaCachePath(customerIdn);
56
+ if (!(await fs.pathExists(cachePath))) return null;
57
+ try {
58
+ return (await fs.readJson(cachePath)) as LiveSchemaSnapshot;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
@@ -0,0 +1,22 @@
1
+ import { textReporter } from './text.js';
2
+ import { jsonReporter } from './json.js';
3
+ import { sarifReporter } from './sarif.js';
4
+ import type { Reporter, ReporterName } from './types.js';
5
+
6
+ export type { Reporter, ReporterName } from './types.js';
7
+
8
+ export function pickReporter(name: ReporterName | string | undefined): Reporter {
9
+ switch (name) {
10
+ case 'json':
11
+ return jsonReporter;
12
+ case 'sarif':
13
+ return sarifReporter;
14
+ case 'text':
15
+ case undefined:
16
+ case '':
17
+ return textReporter;
18
+ default:
19
+ console.warn(`Unknown --format value '${name}', defaulting to text.`);
20
+ return textReporter;
21
+ }
22
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Machine-readable JSON reporter. Matches the ProjectLintReport shape
3
+ * exactly - consumers can parse with any JSON tool.
4
+ */
5
+ import type { ProjectLintReport } from 'newo-dsl-analyzer';
6
+ import type { Reporter } from './types.js';
7
+
8
+ export const jsonReporter: Reporter = {
9
+ write(report: ProjectLintReport): string {
10
+ return JSON.stringify(report, null, 2);
11
+ },
12
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * SARIF 2.1.0 reporter - lets GitHub Advanced Security / Code Scanning
3
+ * pick up newo-lint findings alongside CodeQL and other linters.
4
+ *
5
+ * Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
6
+ */
7
+ import type { ProjectLintReport, Diagnostic } from 'newo-dsl-analyzer';
8
+ import type { Reporter } from './types.js';
9
+
10
+ export const sarifReporter: Reporter = {
11
+ write(report: ProjectLintReport): string {
12
+ const sarif = {
13
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
14
+ version: '2.1.0',
15
+ runs: [
16
+ {
17
+ tool: {
18
+ driver: {
19
+ name: 'newo-lint',
20
+ informationUri: 'https://github.com/sabbah13/newo-cli',
21
+ rules: [] as Array<{ id: string }>,
22
+ },
23
+ },
24
+ results: report.results.flatMap(r =>
25
+ r.diagnostics.map(d => buildResult(r.filePath, d)),
26
+ ),
27
+ },
28
+ ],
29
+ };
30
+ return JSON.stringify(sarif, null, 2);
31
+ },
32
+ };
33
+
34
+ function buildResult(filePath: string, d: Diagnostic): Record<string, unknown> {
35
+ return {
36
+ ruleId: d.code,
37
+ level: d.severity === 'error' ? 'error' : d.severity === 'warning' ? 'warning' : 'note',
38
+ message: { text: d.message },
39
+ locations: [
40
+ {
41
+ physicalLocation: {
42
+ artifactLocation: { uri: toUri(filePath) },
43
+ region: {
44
+ startLine: d.range.start.line,
45
+ startColumn: d.range.start.column,
46
+ endLine: d.range.end.line,
47
+ endColumn: d.range.end.column,
48
+ },
49
+ },
50
+ },
51
+ ],
52
+ };
53
+ }
54
+
55
+ function toUri(absPath: string): string {
56
+ // SARIF artifact URIs should be workspace-relative when possible.
57
+ const rel = absPath.replace(process.cwd() + '/', '');
58
+ return rel.replace(/\\/g, '/');
59
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Human-readable terminal reporter. Mirrors the ESLint 'stylish' layout:
3
+ *
4
+ * path/to/file.jinja
5
+ * 12:5 error Unknown skill: foo. Did you mean: bar? E100
6
+ * ...
7
+ *
8
+ * 2 problems (1 error, 1 warning)
9
+ */
10
+ import path from 'path';
11
+ import type { ProjectLintReport, LintResult } from 'newo-dsl-analyzer';
12
+ import type { Reporter } from './types.js';
13
+
14
+ const RED = '\x1b[31m';
15
+ const YELLOW = '\x1b[33m';
16
+ const CYAN = '\x1b[36m';
17
+ const GREY = '\x1b[90m';
18
+ const RESET = '\x1b[0m';
19
+ const BOLD = '\x1b[1m';
20
+
21
+ export const textReporter: Reporter = {
22
+ write(report: ProjectLintReport): string {
23
+ const lines: string[] = [];
24
+ const filesWithIssues = report.results.filter(r => r.diagnostics.length > 0);
25
+
26
+ for (const result of filesWithIssues) {
27
+ lines.push(renderFile(result));
28
+ lines.push('');
29
+ }
30
+
31
+ lines.push(renderSummary(report));
32
+ return lines.join('\n');
33
+ },
34
+ };
35
+
36
+ function renderFile(result: LintResult): string {
37
+ const rel = path.relative(process.cwd(), result.filePath);
38
+ const header = `${BOLD}${CYAN}${rel}${RESET}`;
39
+ const rows = result.diagnostics.map(d => {
40
+ const loc = `${d.range.start.line}:${d.range.start.column}`;
41
+ const sev = d.severity === 'error'
42
+ ? `${RED}error${RESET}`
43
+ : d.severity === 'warning'
44
+ ? `${YELLOW}warning${RESET}`
45
+ : `${GREY}${d.severity}${RESET}`;
46
+ return ` ${loc.padEnd(7)} ${sev.padEnd(16)} ${d.message} ${GREY}${d.code}${RESET}`;
47
+ });
48
+ return [header, ...rows].join('\n');
49
+ }
50
+
51
+ function renderSummary(report: ProjectLintReport): string {
52
+ const total = report.errorCount + report.warningCount;
53
+ if (total === 0) {
54
+ return `${GREY}No issues found.${RESET}`;
55
+ }
56
+ const color = report.errorCount > 0 ? RED : YELLOW;
57
+ return `${color}${BOLD}${total} problems${RESET} (${report.errorCount} error${report.errorCount === 1 ? '' : 's'}, ${report.warningCount} warning${report.warningCount === 1 ? '' : 's'})`;
58
+ }
@@ -0,0 +1,7 @@
1
+ import type { ProjectLintReport } from 'newo-dsl-analyzer';
2
+
3
+ export type ReporterName = 'text' | 'json' | 'sarif';
4
+
5
+ export interface Reporter {
6
+ write(report: ProjectLintReport): string;
7
+ }
@@ -12,6 +12,11 @@ import path from 'path';
12
12
  import fs from 'fs-extra';
13
13
  import yaml from 'js-yaml';
14
14
  import { patchYamlToPyyaml } from '../format/yaml-patch.js';
15
+ import {
16
+ isJsonValueType,
17
+ normalizeJsonValueForStorage,
18
+ jsonValuesEqual
19
+ } from './json-attr-utils.js';
15
20
  import type { AxiosInstance } from 'axios';
16
21
  import type { CustomerConfig } from '../types.js';
17
22
 
@@ -43,15 +48,21 @@ export async function saveCustomerAttributes(
43
48
  idMapping[attr.idn] = attr.id;
44
49
  }
45
50
 
46
- // Special handling for complex JSON string values
51
+ // Coerce JSON-typed values to a STRING. The API can return the value
52
+ // as a parsed object for `value_type: json` attributes; if we let
53
+ // yaml.dump serialize that as a YAML structure the next push sends
54
+ // `{"value": {...}}` instead of `{"value": "..."}` and the Workflow
55
+ // Builder canvas breaks. See src/sync/json-attr-utils.ts for the
56
+ // full rationale.
47
57
  let processedValue = attr.value;
48
- if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
58
+ if (isJsonValueType(attr.value_type)) {
59
+ processedValue = normalizeJsonValueForStorage(attr.value);
60
+ } else if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
61
+ // Legacy: reformat array-of-objects JSON strings for readability
49
62
  try {
50
- // Parse and reformat JSON for better readability
51
63
  const parsed = JSON.parse(attr.value);
52
- processedValue = JSON.stringify(parsed, null, 0); // No extra spacing, but valid JSON
64
+ processedValue = JSON.stringify(parsed, null, 0); // compact, valid JSON
53
65
  } catch (e) {
54
- // Keep original if parsing fails
55
66
  processedValue = attr.value;
56
67
  }
57
68
  }
@@ -145,9 +156,12 @@ export async function saveProjectAttributes(
145
156
  idMapping[attr.idn] = attr.id;
146
157
  }
147
158
 
148
- // Special handling for complex JSON string values
159
+ // Coerce JSON-typed values to a STRING. See json-attr-utils.ts for
160
+ // why this matters (Workflow Builder canvas blank-screen bug).
149
161
  let processedValue = attr.value;
150
- if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
162
+ if (isJsonValueType(attr.value_type)) {
163
+ processedValue = normalizeJsonValueForStorage(attr.value);
164
+ } else if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
151
165
  try {
152
166
  const parsed = JSON.parse(attr.value);
153
167
  processedValue = JSON.stringify(parsed, null, 0);
@@ -297,19 +311,34 @@ export async function pushProjectAttributes(
297
311
 
298
312
  // Value type is already parsed (we removed !enum tags above)
299
313
  const valueType = localAttr.value_type;
300
-
301
- // Check if value changed (use ?? to preserve 0, false, empty string)
302
- const localValue = String(localAttr.value ?? '');
303
- const remoteValue = String(remoteAttr.value ?? '');
304
-
305
- if (localValue !== remoteValue) {
314
+ const isJson = isJsonValueType(valueType);
315
+
316
+ // Check if value changed.
317
+ // For JSON-typed values, compare canonical (compact) JSON so that
318
+ // pretty- vs compact-printed forms don't register as changes and
319
+ // string vs object representations compare equal. For everything
320
+ // else, fall back to the existing String() comparison (which still
321
+ // preserves 0, false, "" via ??).
322
+ const valuesAreEqual = isJson
323
+ ? jsonValuesEqual(localAttr.value, remoteAttr.value)
324
+ : String(localAttr.value ?? '') === String(remoteAttr.value ?? '');
325
+
326
+ if (!valuesAreEqual) {
306
327
  if (verbose) console.log(` 🔄 Updating project attribute: ${localAttr.idn}`);
307
328
 
308
329
  try {
330
+ // Always send JSON-typed values as a STRING. If the API or our
331
+ // YAML loader handed us an object, the platform stores it
332
+ // differently from the original string and the Workflow Builder
333
+ // canvas blanks out.
334
+ const valueToSend = isJson
335
+ ? normalizeJsonValueForStorage(localAttr.value)
336
+ : localAttr.value;
337
+
309
338
  const attributeToUpdate = {
310
339
  id: attributeId,
311
340
  idn: localAttr.idn,
312
- value: localAttr.value,
341
+ value: valueToSend,
313
342
  title: localAttr.title,
314
343
  description: localAttr.description,
315
344
  group: localAttr.group,