@tenonhq/sincronia-core 0.0.78 → 0.0.79

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/commands.js CHANGED
@@ -43,6 +43,7 @@ exports.downloadCommand = downloadCommand;
43
43
  exports.initCommand = initCommand;
44
44
  exports.buildCommand = buildCommand;
45
45
  exports.deployCommand = deployCommand;
46
+ exports.taskClearCommand = taskClearCommand;
46
47
  exports.statusCommand = statusCommand;
47
48
  const ConfigManager = __importStar(require("./config"));
48
49
  const AppUtils = __importStar(require("./appUtils"));
@@ -54,6 +55,8 @@ const snClient_1 = require("./snClient");
54
55
  const inquirer_1 = __importDefault(require("inquirer"));
55
56
  const gitUtils_1 = require("./gitUtils");
56
57
  const FileUtils_1 = require("./FileUtils");
58
+ const path = __importStar(require("path"));
59
+ const fs = __importStar(require("fs"));
57
60
  function setLogLevel(args) {
58
61
  Logger_1.logger.setLogLevel(args.logLevel);
59
62
  }
@@ -300,6 +303,26 @@ async function deployCommand(args) {
300
303
  throw e;
301
304
  }
302
305
  }
306
+ async function taskClearCommand(args) {
307
+ setLogLevel(args);
308
+ var taskPath = path.resolve(process.cwd(), ".sinc-active-task.json");
309
+ if (fs.existsSync(taskPath)) {
310
+ try {
311
+ var parsed = JSON.parse(fs.readFileSync(taskPath, "utf8"));
312
+ var taskName = parsed.taskName || parsed.taskId || "unknown";
313
+ fs.unlinkSync(taskPath);
314
+ Logger_1.logger.success("Active task '" + taskName + "' cleared.");
315
+ }
316
+ catch (e) {
317
+ // File exists but can't be parsed — still remove it
318
+ fs.unlinkSync(taskPath);
319
+ Logger_1.logger.success("Active task file removed.");
320
+ }
321
+ }
322
+ else {
323
+ Logger_1.logger.info("No active task is currently set.");
324
+ }
325
+ }
303
326
  async function statusCommand() {
304
327
  try {
305
328
  const client = (0, snClient_1.defaultClient)();
@@ -308,6 +331,17 @@ async function statusCommand() {
308
331
  Logger_1.logger.info("Instance: " + (process.env.SN_INSTANCE || "not set"));
309
332
  Logger_1.logger.info("User: " + (process.env.SN_USER || "not set"));
310
333
  Logger_1.logger.info("Active scope: " + scopeObj.scope);
334
+ // Read update set config
335
+ var updateSetConfig = {};
336
+ var updateSetConfigPath = path.resolve(process.cwd(), ".sinc-update-sets.json");
337
+ try {
338
+ if (fs.existsSync(updateSetConfigPath)) {
339
+ updateSetConfig = JSON.parse(fs.readFileSync(updateSetConfigPath, "utf8"));
340
+ }
341
+ }
342
+ catch (e) {
343
+ Logger_1.logger.warn("Failed to parse .sinc-update-sets.json: " + (e instanceof Error ? e.message : String(e)));
344
+ }
311
345
  if (config.scopes) {
312
346
  var scopeNames = Object.keys(config.scopes);
313
347
  Logger_1.logger.info("\nConfigured scopes (" + scopeNames.length + "):");
@@ -318,7 +352,10 @@ async function statusCommand() {
318
352
  ? scopeConf.sourceDirectory
319
353
  : "src/" + scopeName;
320
354
  var marker = scopeName === scopeObj.scope ? " (active)" : "";
321
- Logger_1.logger.info(" " + scopeName + marker + " — " + srcDir);
355
+ var updateSetInfo = updateSetConfig[scopeName]
356
+ ? " [update set: " + updateSetConfig[scopeName].name + "]"
357
+ : " [no update set configured]";
358
+ Logger_1.logger.info(" " + scopeName + marker + " — " + srcDir + updateSetInfo);
322
359
  }
323
360
  }
324
361
  }
package/dist/config.js CHANGED
@@ -223,7 +223,7 @@ function generateConfigFile(params) {
223
223
  "\t\t\tmatch: /sys_script_include.*\\.ts$/,\n" +
224
224
  "\t\t\tplugins: [\n" +
225
225
  "\t\t\t\t{\n" +
226
- "\t\t\t\t\tname: \"@sincronia/typescript-plugin\",\n" +
226
+ "\t\t\t\t\tname: \"@tenonhq/sincronia-typescript-plugin\",\n" +
227
227
  "\t\t\t\t\toptions: {\n" +
228
228
  "\t\t\t\t\t\ttranspile: true,\n" +
229
229
  "\t\t\t\t\t\ttypeCheck: true,\n" +
@@ -231,7 +231,7 @@ function generateConfigFile(params) {
231
231
  "\t\t\t\t\t},\n" +
232
232
  "\t\t\t\t},\n" +
233
233
  "\t\t\t\t{\n" +
234
- "\t\t\t\t\tname: \"@sincronia/eslint-plugin\",\n" +
234
+ "\t\t\t\t\tname: \"@tenonhq/sincronia-eslint-plugin\",\n" +
235
235
  "\t\t\t\t\toptions: {\n" +
236
236
  "\t\t\t\t\t\tconfigFile: \"./.eslintrc.js\",\n" +
237
237
  "\t\t\t\t\t\tfix: false,\n" +
@@ -239,13 +239,13 @@ function generateConfigFile(params) {
239
239
  "\t\t\t\t\t},\n" +
240
240
  "\t\t\t\t},\n" +
241
241
  "\t\t\t\t{\n" +
242
- "\t\t\t\t\tname: \"@sincronia/babel-plugin\",\n" +
242
+ "\t\t\t\t\tname: \"@tenonhq/sincronia-babel-plugin\",\n" +
243
243
  "\t\t\t\t\toptions: {\n" +
244
244
  "\t\t\t\t\t\tconfigFile: \"./.babelrc\",\n" +
245
245
  "\t\t\t\t\t},\n" +
246
246
  "\t\t\t\t},\n" +
247
247
  "\t\t\t\t{\n" +
248
- "\t\t\t\t\tname: \"@sincronia/prettier-plugin\",\n" +
248
+ "\t\t\t\t\tname: \"@tenonhq/sincronia-prettier-plugin\",\n" +
249
249
  "\t\t\t\t\toptions: {\n" +
250
250
  "\t\t\t\t\t\tconfigFile: \"./.prettierrc.js\",\n" +
251
251
  "\t\t\t\t\t},\n" +
package/dist/snClient.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.unwrapSNResponse = exports.defaultClient = exports.snClient = exports.processPushResponse = exports.retryOnErr = void 0;
6
+ exports.unwrapSNResponse = exports.defaultClient = exports.snClient = exports.processPushResponse = exports.retryOnHttpErr = exports.retryOnErr = void 0;
7
7
  exports.unwrapTableAPIFirstItem = unwrapTableAPIFirstItem;
8
8
  const axios_1 = __importDefault(require("axios"));
9
9
  const axios_cookiejar_support_1 = require("axios-cookiejar-support");
@@ -41,6 +41,86 @@ const retryOnErr = async (f, allowedRetries, msBetween = 0, onRetry) => {
41
41
  }
42
42
  };
43
43
  exports.retryOnErr = retryOnErr;
44
+ function _getHttpStatus(e) {
45
+ if (axios_1.default.isAxiosError(e) && e.response) {
46
+ return e.response.status;
47
+ }
48
+ return undefined;
49
+ }
50
+ function _getRetryAfterMs(e) {
51
+ if (axios_1.default.isAxiosError(e) && e.response && e.response.headers) {
52
+ var retryAfter = e.response.headers["retry-after"];
53
+ if (retryAfter) {
54
+ var seconds = Number(retryAfter);
55
+ if (!isNaN(seconds) && seconds > 0) {
56
+ return seconds * 1000;
57
+ }
58
+ }
59
+ }
60
+ return 10000; // Default 10s for 429 without Retry-After
61
+ }
62
+ const retryOnHttpErr = async (f, recSummary) => {
63
+ var maxServerRetries = 3;
64
+ var backoffMs = 1000;
65
+ var attempt = 0;
66
+ while (true) {
67
+ try {
68
+ return await f();
69
+ }
70
+ catch (e) {
71
+ var status = _getHttpStatus(e);
72
+ attempt++;
73
+ // 401/403: Auth failure — fail immediately
74
+ if (status === 401 || status === 403) {
75
+ var authMsg = status === 401 ? "Unauthorized" : "Forbidden";
76
+ Logger_1.logger.error(authMsg + " (" + status + ") pushing " + recSummary +
77
+ ". Verify your ServiceNow credentials and permissions.");
78
+ throw e;
79
+ }
80
+ // 404: Record not found — fail immediately
81
+ if (status === 404) {
82
+ Logger_1.logger.error("Record not found (404) pushing " + recSummary +
83
+ ". The record may have been deleted from the instance.");
84
+ throw e;
85
+ }
86
+ // 429: Rate limited — honor Retry-After, then retry
87
+ if (status === 429) {
88
+ var retryWait = _getRetryAfterMs(e);
89
+ Logger_1.logger.warn("Rate limited (429) pushing " + recSummary +
90
+ ". Waiting " + Math.round(retryWait / 1000) + "s before retry.");
91
+ await (0, genericUtils_1.wait)(retryWait);
92
+ continue;
93
+ }
94
+ // 500/502/503: Server error — exponential backoff, max 3 retries
95
+ if (status === 500 || status === 502 || status === 503) {
96
+ if (attempt > maxServerRetries) {
97
+ Logger_1.logger.error("Server error (" + status + ") pushing " + recSummary +
98
+ " after " + maxServerRetries + " retries. Giving up.");
99
+ throw e;
100
+ }
101
+ var cappedBackoff = Math.min(backoffMs, 8000);
102
+ Logger_1.logger.warn("Server error (" + status + ") pushing " + recSummary +
103
+ ". Retrying in " + (cappedBackoff / 1000) + "s (" +
104
+ (maxServerRetries - attempt) + " retries left).");
105
+ await (0, genericUtils_1.wait)(cappedBackoff);
106
+ backoffMs = backoffMs * 2;
107
+ continue;
108
+ }
109
+ // Unknown status or non-HTTP error — retry once, then fail
110
+ if (attempt > 1) {
111
+ var errDetail = status ? "HTTP " + status : "unknown error";
112
+ Logger_1.logger.error("Push failed for " + recSummary + " (" + errDetail +
113
+ ") after 1 retry. Giving up.");
114
+ throw e;
115
+ }
116
+ var retryDetail = status ? "HTTP " + status : "unknown error";
117
+ Logger_1.logger.warn("Unexpected error (" + retryDetail + ") pushing " + recSummary +
118
+ ". Retrying once.");
119
+ await (0, genericUtils_1.wait)(1000);
120
+ }
121
+ }
122
+ };
123
+ exports.retryOnHttpErr = retryOnHttpErr;
44
124
  const processPushResponse = (response, recSummary) => {
45
125
  const { status } = response;
46
126
  if (status === 404) {
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+ /**
3
+ * Tests for US-006: Surface warning when no update set is configured
4
+ *
5
+ * Validates:
6
+ * - Prominent warning when readActiveTask() returns null and no update set configured
7
+ * - Warning message includes specific text about Default and remediation steps
8
+ * - Invalid scope name produces a clear error (not a global-scope update set)
9
+ * - scopeSysId validated before creating update set; null/undefined skips with error
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ // --- Mock setup ---
13
+ const mockSNClient = {
14
+ getScopeId: jest.fn(),
15
+ getUserSysId: jest.fn(),
16
+ getCurrentAppUserPrefSysId: jest.fn(),
17
+ updateCurrentAppUserPref: jest.fn(),
18
+ createCurrentAppUserPref: jest.fn(),
19
+ changeScope: jest.fn().mockResolvedValue(undefined),
20
+ createUpdateSet: jest.fn(),
21
+ changeUpdateSet: jest.fn(),
22
+ client: {
23
+ get: jest.fn(),
24
+ },
25
+ };
26
+ jest.mock("../snClient", () => ({
27
+ defaultClient: jest.fn(() => mockSNClient),
28
+ unwrapSNResponse: jest.fn((val) => val),
29
+ }));
30
+ jest.mock("../FileUtils", () => ({
31
+ getFileContextFromPath: jest.fn(),
32
+ getFileContextWithSkipReason: jest.fn(),
33
+ }));
34
+ jest.mock("../appUtils", () => ({
35
+ groupAppFiles: jest.fn(),
36
+ pushFiles: jest.fn(),
37
+ }));
38
+ jest.mock("../logMessages", () => ({
39
+ logFilePush: jest.fn(),
40
+ }));
41
+ jest.mock("../recentEdits", () => ({
42
+ writeRecentEdit: jest.fn(),
43
+ }));
44
+ jest.mock("../Logger", () => ({
45
+ logger: {
46
+ info: jest.fn(),
47
+ error: jest.fn(),
48
+ warn: jest.fn(),
49
+ debug: jest.fn(),
50
+ success: jest.fn(),
51
+ getLogLevel: jest.fn().mockReturnValue("info"),
52
+ },
53
+ }));
54
+ jest.mock("../config", () => ({
55
+ loadConfigs: jest.fn().mockResolvedValue(undefined),
56
+ getConfig: jest.fn(),
57
+ getRootDir: jest.fn().mockReturnValue("/project"),
58
+ updateManifest: jest.fn(),
59
+ getManifest: jest.fn(),
60
+ getSourcePath: jest.fn().mockReturnValue("/project/src"),
61
+ getScopeManifestPath: jest.fn((scope) => `/project/sinc.manifest.${scope}.json`),
62
+ getManifestPath: jest.fn().mockReturnValue("/project/sinc.manifest.json"),
63
+ }));
64
+ // Filesystem mock store
65
+ var mockFsStore = {};
66
+ jest.mock("fs", () => ({
67
+ existsSync: jest.fn((p) => {
68
+ return p in mockFsStore;
69
+ }),
70
+ readFileSync: jest.fn((p) => {
71
+ if (p in mockFsStore)
72
+ return mockFsStore[p];
73
+ throw new Error("ENOENT: " + p);
74
+ }),
75
+ writeFileSync: jest.fn((p, data) => {
76
+ mockFsStore[p] = data;
77
+ }),
78
+ statSync: jest.fn(() => ({ mtimeMs: Date.now() })),
79
+ promises: {
80
+ readFile: jest.fn(),
81
+ writeFile: jest.fn(),
82
+ readdir: jest.fn(),
83
+ mkdir: jest.fn(),
84
+ access: jest.fn(),
85
+ stat: jest.fn(),
86
+ },
87
+ }));
88
+ jest.mock("chokidar", () => ({
89
+ watch: jest.fn(() => ({
90
+ on: jest.fn().mockReturnThis(),
91
+ close: jest.fn(),
92
+ })),
93
+ }));
94
+ jest.mock("lodash", () => {
95
+ var actual = jest.requireActual("lodash");
96
+ return {
97
+ ...actual,
98
+ debounce: jest.fn((fn) => {
99
+ var wrapper = jest.fn();
100
+ wrapper.cancel = jest.fn();
101
+ wrapper.flush = jest.fn(() => fn());
102
+ return wrapper;
103
+ }),
104
+ };
105
+ });
106
+ jest.mock("axios", () => ({
107
+ default: {
108
+ create: jest.fn(() => ({
109
+ get: jest.fn().mockResolvedValue({ data: { result: null } }),
110
+ })),
111
+ },
112
+ }));
113
+ // --- Imports ---
114
+ const Logger_1 = require("../Logger");
115
+ const MultiScopeWatcher_1 = require("../MultiScopeWatcher");
116
+ // --- Tests ---
117
+ describe("US-006: Surface warning when no update set is configured", () => {
118
+ beforeEach(() => {
119
+ jest.clearAllMocks();
120
+ mockFsStore = {};
121
+ // Default: scope switching succeeds
122
+ mockSNClient.getScopeId.mockResolvedValue([{ sys_id: "scope_sys_id" }]);
123
+ mockSNClient.getUserSysId.mockResolvedValue([{ sys_id: "user_sys_id" }]);
124
+ mockSNClient.getCurrentAppUserPrefSysId.mockResolvedValue([{ sys_id: "pref_sys_id" }]);
125
+ mockSNClient.updateCurrentAppUserPref.mockResolvedValue({});
126
+ mockSNClient.createCurrentAppUserPref.mockResolvedValue({});
127
+ });
128
+ afterEach(() => {
129
+ (0, MultiScopeWatcher_1.stopMultiScopeWatching)();
130
+ });
131
+ it("warns with specific message when no active task and no update set configured", async () => {
132
+ // No .sinc-update-sets.json and no .sinc-active-task.json
133
+ await MultiScopeWatcher_1.multiScopeWatcher.ensureUpdateSetForScope("x_test_core");
134
+ expect(Logger_1.logger.warn).toHaveBeenCalledWith(expect.stringContaining("No update set configured for scope x_test_core"));
135
+ expect(Logger_1.logger.warn).toHaveBeenCalledWith(expect.stringContaining("Changes will go to Default"));
136
+ expect(Logger_1.logger.warn).toHaveBeenCalledWith(expect.stringContaining("sinc createUpdateSet"));
137
+ expect(Logger_1.logger.warn).toHaveBeenCalledWith(expect.stringContaining("activate a task in the dashboard"));
138
+ });
139
+ it("does not warn when update set already configured for scope", async () => {
140
+ // Pre-populate the update set config
141
+ var configPath = require("path").resolve(process.cwd(), ".sinc-update-sets.json");
142
+ mockFsStore[configPath] = JSON.stringify({
143
+ x_test_core: { sys_id: "us_123", name: "My Update Set" }
144
+ });
145
+ await MultiScopeWatcher_1.multiScopeWatcher.ensureUpdateSetForScope("x_test_core");
146
+ // Should return early — no warning
147
+ expect(Logger_1.logger.warn).not.toHaveBeenCalled();
148
+ expect(Logger_1.logger.error).not.toHaveBeenCalled();
149
+ });
150
+ it("logs error and skips creation when scope not found on instance (scopeSysId is undefined)", async () => {
151
+ // Active task exists so we get past the no-task check
152
+ var taskPath = require("path").resolve(process.cwd(), ".sinc-active-task.json");
153
+ mockFsStore[taskPath] = JSON.stringify({
154
+ taskId: "abc123",
155
+ taskName: "Test Task",
156
+ taskDescription: "Test",
157
+ updateSetName: "CU-abc123 Test Task",
158
+ description: "Test update set",
159
+ taskUrl: "https://example.com",
160
+ scopes: {},
161
+ });
162
+ // getScopeId returns empty — scope doesn't exist
163
+ mockSNClient.getScopeId.mockResolvedValue([]);
164
+ await MultiScopeWatcher_1.multiScopeWatcher.ensureUpdateSetForScope("x_invalid_scope");
165
+ expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("x_invalid_scope"));
166
+ expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("not found on the instance"));
167
+ // Should NOT attempt to create an update set
168
+ expect(mockSNClient.createUpdateSet).not.toHaveBeenCalled();
169
+ expect(mockSNClient.client.get).not.toHaveBeenCalled();
170
+ });
171
+ it("logs error and skips creation when getScopeId returns null", async () => {
172
+ var taskPath = require("path").resolve(process.cwd(), ".sinc-active-task.json");
173
+ mockFsStore[taskPath] = JSON.stringify({
174
+ taskId: "abc123",
175
+ taskName: "Test Task",
176
+ taskDescription: "Test",
177
+ updateSetName: "CU-abc123 Test Task",
178
+ description: "Test update set",
179
+ taskUrl: "https://example.com",
180
+ scopes: {},
181
+ });
182
+ // getScopeId returns null
183
+ mockSNClient.getScopeId.mockResolvedValue(null);
184
+ await MultiScopeWatcher_1.multiScopeWatcher.ensureUpdateSetForScope("x_bad_scope");
185
+ expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("x_bad_scope"));
186
+ expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("not found on the instance"));
187
+ expect(mockSNClient.createUpdateSet).not.toHaveBeenCalled();
188
+ });
189
+ it("proceeds to create update set when scope is valid", async () => {
190
+ var taskPath = require("path").resolve(process.cwd(), ".sinc-active-task.json");
191
+ mockFsStore[taskPath] = JSON.stringify({
192
+ taskId: "abc123",
193
+ taskName: "Test Task",
194
+ taskDescription: "Test",
195
+ updateSetName: "CU-abc123 Test Task",
196
+ description: "Test update set",
197
+ taskUrl: "https://example.com",
198
+ scopes: {},
199
+ });
200
+ // Valid scope
201
+ mockSNClient.getScopeId.mockResolvedValue([{ sys_id: "valid_scope_sys_id" }]);
202
+ // Search returns existing update set
203
+ mockSNClient.client.get.mockResolvedValue({
204
+ data: { result: [{ sys_id: "us_existing", name: "CU-abc123 Test Task", state: "in progress" }] }
205
+ });
206
+ // changeUpdateSet succeeds
207
+ mockSNClient.changeUpdateSet.mockResolvedValue(undefined);
208
+ await MultiScopeWatcher_1.multiScopeWatcher.ensureUpdateSetForScope("x_valid_scope");
209
+ // Should have searched for existing update sets (scope was valid)
210
+ expect(mockSNClient.client.get).toHaveBeenCalled();
211
+ // No "not found on the instance" error
212
+ var errorCalls = Logger_1.logger.error.mock.calls.map(function (c) { return c[0]; });
213
+ var hasInvalidScopeError = errorCalls.some(function (msg) {
214
+ return msg.indexOf("not found on the instance") !== -1;
215
+ });
216
+ expect(hasInvalidScopeError).toBe(false);
217
+ });
218
+ });