@fromeroc9/testform 1.0.3 → 1.0.5

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 (50) hide show
  1. package/dist/action/index.js +1 -1
  2. package/dist/action.js +60 -0
  3. package/dist/adapters/github.js +467 -0
  4. package/dist/adapters/resources.js +363 -0
  5. package/dist/cli/index.js +3 -3
  6. package/dist/commands/apply.js +390 -0
  7. package/dist/commands/destroy.js +85 -0
  8. package/dist/commands/diff.js +131 -0
  9. package/dist/commands/fmt.js +166 -0
  10. package/dist/commands/force-unlock.js +55 -0
  11. package/dist/commands/generate.js +143 -0
  12. package/dist/commands/graph.js +159 -0
  13. package/dist/commands/import.js +222 -0
  14. package/dist/commands/init.js +167 -0
  15. package/dist/commands/login.js +71 -0
  16. package/dist/commands/logout.js +20 -0
  17. package/dist/commands/plan.js +250 -0
  18. package/dist/commands/refresh.js +165 -0
  19. package/dist/commands/report.js +724 -0
  20. package/dist/commands/show.js +61 -0
  21. package/dist/commands/state.js +197 -0
  22. package/dist/commands/taint.js +49 -0
  23. package/dist/commands/validate.js +128 -0
  24. package/dist/commands/workspace.js +102 -0
  25. package/dist/const.js +105 -0
  26. package/dist/core/backends/azurerm.js +201 -0
  27. package/dist/core/backends/backend.js +2 -0
  28. package/dist/core/backends/gcs.js +200 -0
  29. package/dist/core/backends/local.js +162 -0
  30. package/dist/core/backends/s3.js +224 -0
  31. package/dist/core/command-context.js +59 -0
  32. package/dist/core/config.js +131 -0
  33. package/dist/core/credentials.js +53 -0
  34. package/dist/core/parser.js +62 -0
  35. package/dist/core/parsers/base-parser.js +215 -0
  36. package/dist/core/parsers/testcase-parser.js +115 -0
  37. package/dist/core/parsers/testplan-parser.js +41 -0
  38. package/dist/core/parsers/testrun-parser.js +43 -0
  39. package/dist/core/policy.js +341 -0
  40. package/dist/core/prompt.js +109 -0
  41. package/dist/core/state.js +185 -0
  42. package/dist/core/utils.js +94 -0
  43. package/dist/core/variables.js +108 -0
  44. package/dist/core/workspace.js +56 -0
  45. package/dist/help.js +797 -0
  46. package/dist/index.js +650 -0
  47. package/dist/logger.js +134 -0
  48. package/dist/notify.js +36 -0
  49. package/dist/types.js +2 -0
  50. package/package.json +1 -1
@@ -0,0 +1,341 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerPolicy = exports.policy = void 0;
4
+ const logger_1 = require("../logger");
5
+ const notify_1 = require("../notify");
6
+ class Policy {
7
+ builtinPolicies = {};
8
+ userPolicies = {};
9
+ resolve(scope) {
10
+ const system = Object.values(this.builtinPolicies);
11
+ const user = Object.values(this.userPolicies);
12
+ return [...system, ...user].filter((policy) => policy.scope.includes(scope));
13
+ }
14
+ registry(policy, type = 'system') {
15
+ if (type === 'system') {
16
+ this.builtinPolicies[policy.id] = policy;
17
+ }
18
+ if (type === 'user') {
19
+ this.userPolicies[policy.id] = policy;
20
+ }
21
+ }
22
+ scanner(scenarios, scope, isJson = false, compactWarnings = false) {
23
+ const rules = [];
24
+ const activePolicies = this.resolve(scope);
25
+ for (const policy of activePolicies) {
26
+ try {
27
+ policy.action(scenarios, rules, scope);
28
+ }
29
+ catch (err) {
30
+ const message = err instanceof Error ? err.message : "Unknown policy execution error";
31
+ rules.push({
32
+ id: `policy-runtime-error:${policy.id}`,
33
+ title: `Policy "${policy.id}" failed during execution.`,
34
+ detail: message,
35
+ uri: "(policy-runtime)",
36
+ });
37
+ }
38
+ }
39
+ if (isJson) {
40
+ if (rules.length > 0) {
41
+ const data = {
42
+ valid: false,
43
+ error_count: rules.length,
44
+ warning_count: 0,
45
+ diagnostics: rules.map(r => ({
46
+ severity: r.type ?? 'error',
47
+ summary: r.title,
48
+ detail: r.detail
49
+ }))
50
+ };
51
+ console.log(JSON.stringify(data, null, 2));
52
+ process.exit(1);
53
+ }
54
+ return;
55
+ }
56
+ if (rules.length > 0) {
57
+ logger_1.logger.info("Review the violations above and fix them before continuing.", { bold: true });
58
+ logger_1.logger.info(`${rules.length} policy violation${rules.length > 1 ? "s" : ""} found:\n`, { bold: true });
59
+ }
60
+ let warningsCount = 0;
61
+ for (const violation of rules) {
62
+ const isWarning = violation.type === 'warning';
63
+ if (isWarning)
64
+ warningsCount++;
65
+ if (isWarning && compactWarnings)
66
+ continue;
67
+ let locationStr = `on ${violation.uri}`;
68
+ if (violation.line !== undefined)
69
+ locationStr += ` line ${violation.line}`;
70
+ if (violation.scenario)
71
+ locationStr += `, in scenario "${violation.scenario}"`;
72
+ notify_1.notify.push({
73
+ type: violation.type ?? 'error',
74
+ title: violation.title,
75
+ detail: [
76
+ violation.detail,
77
+ locationStr
78
+ ]
79
+ });
80
+ }
81
+ if (warningsCount > 0 && compactWarnings) {
82
+ logger_1.logger.warn(`\n${warningsCount} warning(s) found. (Details suppressed by -compact-warnings)`);
83
+ }
84
+ return rules.length > 0;
85
+ }
86
+ }
87
+ exports.policy = new Policy();
88
+ const registerPolicy = (policyDefinition) => exports.policy.registry(policyDefinition, 'user');
89
+ exports.registerPolicy = registerPolicy;
90
+ exports.policy.registry({
91
+ id: "undeclared-fields",
92
+ scope: ["testcase"],
93
+ action: (scenarios, rules, scope) => {
94
+ for (const scenario of scenarios) {
95
+ for (const v of scenario.custom?.policy?.filter(v => v.type === 'undeclared-field') ?? []) {
96
+ rules.push({
97
+ id: "undeclared-field",
98
+ type: 'warning',
99
+ title: `Value for undeclared field "${v.field}"`,
100
+ detail: `Field "${v.field}" is not declared in the configuration. Add a "fields" entry to your config.`,
101
+ uri: scenario.uri,
102
+ scenario: scenario.name,
103
+ line: scenario.location,
104
+ });
105
+ }
106
+ }
107
+ }
108
+ });
109
+ exports.policy.registry({
110
+ id: "required-fields",
111
+ scope: ["testcase", "testrun", "testplan"],
112
+ action: (scenarios, rules, scope) => {
113
+ for (const scenario of scenarios) {
114
+ for (const v of scenario.custom?.policy?.filter(v => v.type === 'required-field') ?? []) {
115
+ rules.push({
116
+ id: "required-field-missing",
117
+ title: `Required field "${v.field}" is missing`,
118
+ detail: `Add a step: "field ${v.field} = <value>" to the scenario.`,
119
+ uri: scenario.uri,
120
+ scenario: scenario.name,
121
+ line: scenario.location,
122
+ });
123
+ }
124
+ }
125
+ }
126
+ });
127
+ exports.policy.registry({
128
+ id: "required-gherkin",
129
+ scope: ["testcase"],
130
+ action: (scenarios, rules, scope) => {
131
+ const validKeywords = ["scenario", "scenario outline"];
132
+ for (const scenario of scenarios) {
133
+ // Check: Feature name is required
134
+ if (!scenario.feature.name || scenario.feature.name.trim() === "") {
135
+ rules.push({
136
+ id: "feature-name-required",
137
+ title: "Feature name is required",
138
+ detail: "Every feature must have a name. Add a descriptive name after 'Feature'.",
139
+ uri: scenario.uri,
140
+ line: scenario.location
141
+ });
142
+ }
143
+ // Check: Scenario name is required
144
+ if (!scenario.name || scenario.name.trim() === "") {
145
+ rules.push({
146
+ id: "scenario-name-required",
147
+ title: "Scenario name is required",
148
+ detail: "Every scenario must have a name. Add a descriptive name after 'Scenario:' or 'Scenario Outline:'.",
149
+ uri: scenario.uri,
150
+ line: scenario.location,
151
+ });
152
+ continue;
153
+ }
154
+ // Check: Valid keyword (Scenario or Scenario Outline)
155
+ if (!validKeywords.includes(scenario.keyword.trim().toLowerCase()))
156
+ continue;
157
+ // Check: Required steps (Given, When, Then)
158
+ const allSteps = [
159
+ ...(scenario.background?.steps ?? []),
160
+ ...scenario.steps,
161
+ ];
162
+ const missing = [];
163
+ if (!allSteps.some(s => s.keyword.trim().toLowerCase() === "given"))
164
+ missing.push("Given");
165
+ if (!allSteps.some(s => s.keyword.trim().toLowerCase() === "when"))
166
+ missing.push("When");
167
+ if (!allSteps.some(s => s.keyword.trim().toLowerCase() === "then"))
168
+ missing.push("Then");
169
+ if (missing.length > 0) {
170
+ rules.push({
171
+ id: "steps-required",
172
+ title: `Scenario is missing required step types: ${missing.join(", ")}.`,
173
+ detail: "Each scenario must have at least one Given, When, and Then step.",
174
+ uri: scenario.uri,
175
+ scenario: scenario.name,
176
+ line: scenario.location,
177
+ });
178
+ }
179
+ }
180
+ }
181
+ });
182
+ exports.policy.registry({
183
+ id: "no-feature-tags",
184
+ scope: ["testcase", "testrun", "testplan"],
185
+ action: (scenarios, rules, scope) => {
186
+ const expectedTag = `@${scope}`;
187
+ const seen = new Set();
188
+ for (const scenario of scenarios) {
189
+ const uri = scenario.uri ?? "(unknown)";
190
+ if (seen.has(uri))
191
+ continue;
192
+ seen.add(uri);
193
+ const allowedScopeTags = ["@testcase", "@testrun", "@testplan"];
194
+ const allowedFeatureTags = [...allowedScopeTags];
195
+ if (scenario.custom?.identity) {
196
+ allowedFeatureTags.push(scenario.custom.identity.toLowerCase());
197
+ }
198
+ const featureTags = scenario.feature?.tags ?? [];
199
+ const invalidTags = featureTags.filter(tag => !allowedFeatureTags.includes(tag.toLowerCase()));
200
+ if (invalidTags.length > 0) {
201
+ rules.push({
202
+ id: "no-feature-tags",
203
+ title: `Feature-level tags are not allowed in ${uri} scope (found: ${invalidTags.join(", ")}).`,
204
+ detail: "Move any tags from Feature: to the individual Scenario level, except for scope tags and identity tags.",
205
+ uri,
206
+ });
207
+ }
208
+ const declaredScopeTags = featureTags.filter(tag => allowedScopeTags.includes(tag.toLowerCase()));
209
+ for (const tag of declaredScopeTags) {
210
+ if (tag.toLowerCase() !== expectedTag) {
211
+ rules.push({
212
+ id: "invalid-scope-tag",
213
+ title: `Mismatched scope: file is tagged as ${tag} but is being processed as ${expectedTag}.`,
214
+ detail: `A feature file designed for ${tag} cannot be executed in -scope ${scope}.`,
215
+ uri,
216
+ });
217
+ }
218
+ }
219
+ }
220
+ }
221
+ });
222
+ exports.policy.registry({
223
+ id: "identity-required",
224
+ scope: ["testcase", "testrun", "testplan"],
225
+ action: (scenarios, rules, scope) => {
226
+ for (const scenario of scenarios ?? []) {
227
+ const identity = scenario.custom?.identity;
228
+ if (!identity || identity.trim() === "" || identity === scenario.uri) {
229
+ rules.push({
230
+ id: "identity-required",
231
+ title: scope === 'testcase'
232
+ ? "Every scenario must have an identity tag matching the configured identity pattern."
233
+ : "Every feature must have an identity tag matching the configured identity pattern.",
234
+ detail: scope === 'testcase'
235
+ ? "Add the identity tag (e.g. @tc-1) to each scenario."
236
+ : "Add the identity tag (e.g. @tr-1) to the Feature level.",
237
+ uri: scenario.uri ?? "(unknown)",
238
+ scenario: scope === 'testcase' ? scenario.name : undefined,
239
+ line: scope === 'testcase' ? scenario.location : (scenario.feature?.location ?? scenario.location),
240
+ });
241
+ continue;
242
+ }
243
+ if (scope === 'testrun' || scope === 'testplan') {
244
+ const featureTags = scenario.feature?.tags ?? [];
245
+ const hasFeatureTag = featureTags.some(t => t.toLowerCase() === identity.toLowerCase());
246
+ if (!hasFeatureTag) {
247
+ rules.push({
248
+ id: "identity-feature-level-required",
249
+ title: `The identity tag ${identity} must be declared at the Feature level.`,
250
+ detail: `Move the identity tag ${identity} so it is placed next to the @${scope} tag before the 'Feature:' keyword.`,
251
+ uri: scenario.uri ?? "(unknown)",
252
+ line: scenario.feature?.location ?? scenario.location,
253
+ });
254
+ }
255
+ }
256
+ }
257
+ }
258
+ });
259
+ exports.policy.registry({
260
+ id: "identity-unique",
261
+ scope: ["testcase", "testrun", "testplan"],
262
+ action: (scenarios, rules, scope) => {
263
+ const seen = new Map();
264
+ for (const scenario of scenarios) {
265
+ const identity = scenario.custom?.identity?.trim();
266
+ if (!identity)
267
+ continue;
268
+ // Ignore if the identity is just the uri fallback
269
+ if (identity === scenario.uri)
270
+ continue;
271
+ const entry = { scenario: scenario.name, line: scenario.location, keyword: scenario.keyword || '', uri: scenario.uri || '' };
272
+ // For testcase, identities only need to be unique within the same file.
273
+ // For testrun/testplan, identities must be globally unique across all files.
274
+ const uniquenessKey = scope === 'testcase' ? `${scenario.uri}::${identity}` : identity;
275
+ const existing = seen.get(uniquenessKey);
276
+ if (existing) {
277
+ existing.push(entry);
278
+ }
279
+ else {
280
+ seen.set(uniquenessKey, [entry]);
281
+ }
282
+ }
283
+ for (const [key, entries] of seen) {
284
+ if (entries.length <= 1)
285
+ continue;
286
+ const identity = key.includes('::') ? key.split('::')[1] : key;
287
+ const allSameOutline = entries.every((e) => (e.keyword || '').trim() === "Scenario Outline") &&
288
+ entries.every((e) => e.line === entries[0].line && e.uri === entries[0].uri);
289
+ if (allSameOutline) {
290
+ rules.push({
291
+ id: "unique-key-required",
292
+ title: `Scenario Outline expanded ${entries.length} rows but all share the same identity "${identity}".`,
293
+ detail: "Add a <key> inside the identity tag (e.g. @tc-<key>) so each expanded row has a unique identity.",
294
+ uri: entries[0].uri,
295
+ scenario: entries[0].scenario,
296
+ line: entries[0].line,
297
+ });
298
+ continue;
299
+ }
300
+ for (const entry of entries) {
301
+ let suggestion = "";
302
+ const match = identity.match(/^(@[a-zA-Z0-9-]+?)-?(\d+)(.*)$/);
303
+ if (match) {
304
+ const prefix = match[1] + (identity.includes('-') ? '-' : '');
305
+ const suffix = match[3];
306
+ let max = 0;
307
+ for (const s of scenarios) {
308
+ // For testcase, only check within the same file. For others, check globally.
309
+ if (scope === 'testcase' && s.uri !== entry.uri)
310
+ continue;
311
+ const id = s.custom?.identity?.trim();
312
+ if (id && id !== s.uri) {
313
+ // Extract numbers from matching prefixes
314
+ const m = id.match(new RegExp(`^${prefix}(\\d+)${suffix}$`));
315
+ if (m) {
316
+ const num = parseInt(m[1], 10);
317
+ if (num > max)
318
+ max = num;
319
+ }
320
+ }
321
+ }
322
+ if (max > 0) {
323
+ suggestion = ` The next available identity ${scope === 'testcase' ? 'in this file ' : ''}is ${prefix}${max + 1}${suffix}.`;
324
+ }
325
+ }
326
+ rules.push({
327
+ id: "identity-unique",
328
+ title: scope === 'testcase'
329
+ ? `Duplicate identity "${identity}" found in the same file.`
330
+ : `Duplicate identity "${identity}" found.`,
331
+ detail: (scope === 'testcase'
332
+ ? "Each scenario within a file must have a unique identity tag."
333
+ : "Each element must have a globally unique identity tag in the workspace.") + suggestion,
334
+ uri: entry.uri,
335
+ scenario: entry.scenario,
336
+ line: entry.line,
337
+ });
338
+ }
339
+ }
340
+ }
341
+ });
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ /**
3
+ * @fileoverview Interactive prompt utilities for CLI confirmation dialogs.
4
+ *
5
+ * Provides approval prompts used by `apply` and `destroy`
6
+ * commands before executing destructive or irreversible operations.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.askConfirmation = askConfirmation;
10
+ exports.askApproval = askApproval;
11
+ exports.askDestroyApproval = askDestroyApproval;
12
+ exports.askMigrationApproval = askMigrationApproval;
13
+ exports.askStatus = askStatus;
14
+ const readline_1 = require("readline");
15
+ const chalk_1 = require("chalk");
16
+ const const_1 = require("../const");
17
+ /**
18
+ * Generic stdin confirmation prompt that accepts only "yes" as approval.
19
+ *
20
+ * @param lines - Lines of text to display before the prompt input.
21
+ * @returns `true` if the user typed "yes" (case-insensitive), `false` otherwise.
22
+ *
23
+ * @example
24
+ * const ok = await askConfirmation(['Are you sure you want to delete everything?']);
25
+ * if (!ok) process.exit(0);
26
+ */
27
+ async function askConfirmation(lines) {
28
+ const rl = (0, readline_1.createInterface)({ input: process.stdin, output: process.stdout });
29
+ return new Promise((resolve) => {
30
+ console.log('');
31
+ for (const line of lines) {
32
+ console.log(line);
33
+ }
34
+ console.log(` ${const_1.MSG_APPROVE_ONLY_YES}`);
35
+ console.log('');
36
+ rl.question(' Enter a value: ', (answer) => {
37
+ rl.close();
38
+ console.log('');
39
+ resolve(answer.trim().toLowerCase() === 'yes');
40
+ });
41
+ });
42
+ }
43
+ /**
44
+ * Apply approval prompt.
45
+ * Shown before `testform apply` executes any planned changes.
46
+ *
47
+ * @returns `true` if the user approved, `false` if they declined.
48
+ */
49
+ async function askApproval() {
50
+ return askConfirmation([
51
+ 'Do you want to perform these actions?',
52
+ ` ${const_1.TITLE_APP} will perform the actions described above.`,
53
+ ]);
54
+ }
55
+ /**
56
+ * Destroy approval prompt.
57
+ * Shown before `testform destroy` removes all managed resources.
58
+ *
59
+ * @param count - Number of resources that will be destroyed.
60
+ * @returns `true` if the user approved, `false` if they declined.
61
+ */
62
+ async function askDestroyApproval(count) {
63
+ return askConfirmation([
64
+ (0, chalk_1.red)((0, chalk_1.bold)(`${const_1.TITLE_APP} will destroy ${count} resource(s).`)),
65
+ ' This action cannot be undone.',
66
+ ]);
67
+ }
68
+ /**
69
+ * Interactive prompt for state migration when backend configuration changes.
70
+ *
71
+ * @param newBackendType - The type of the new backend being configured.
72
+ * @returns `true` if the user wants to copy state, `false` to start empty.
73
+ */
74
+ async function askMigrationApproval(newBackendType) {
75
+ const rl = (0, readline_1.createInterface)({ input: process.stdin, output: process.stdout });
76
+ return new Promise((resolve) => {
77
+ console.log('');
78
+ console.log((0, chalk_1.bold)('Do you want to copy existing state to the new backend?'));
79
+ console.log(' Pre-existing state was found while migrating the previous');
80
+ console.log(` backend to the newly configured "${newBackendType}" backend.`);
81
+ console.log(' Do you want to copy this state to the new backend?');
82
+ console.log(' Enter "yes" to copy and "no" to start with an empty state.');
83
+ console.log('');
84
+ rl.question(' Enter a value: ', (answer) => {
85
+ rl.close();
86
+ console.log('');
87
+ resolve(answer.trim().toLowerCase() === 'yes');
88
+ });
89
+ });
90
+ }
91
+ /**
92
+ * Interactive prompt for selecting a status.
93
+ *
94
+ * @returns The selected status string.
95
+ */
96
+ async function askStatus() {
97
+ const rl = (0, readline_1.createInterface)({ input: process.stdin, output: process.stdout });
98
+ return new Promise((resolve) => {
99
+ console.log('');
100
+ console.log((0, chalk_1.bold)('Select the new status for this testcase:'));
101
+ console.log(' Available options: passed, failed, pending, skipped, blocked, etc.');
102
+ console.log('');
103
+ rl.question(' Enter the new status: ', (answer) => {
104
+ rl.close();
105
+ console.log('');
106
+ resolve(answer.trim().toLowerCase());
107
+ });
108
+ });
109
+ }
@@ -0,0 +1,185 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.State = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const const_1 = require("../const");
6
+ const notify_1 = require("../notify");
7
+ const local_1 = require("./backends/local");
8
+ const s3_1 = require("./backends/s3");
9
+ const azurerm_1 = require("./backends/azurerm");
10
+ const gcs_1 = require("./backends/gcs");
11
+ const config_1 = require("./config");
12
+ const workspace_1 = require("./workspace");
13
+ class State {
14
+ state;
15
+ backend;
16
+ workspaceManager;
17
+ constructor(dir, customStatePath, customBackupPath, disableBackend = false, backendConfigRaw, explicitBackendConfig) {
18
+ const config = new config_1.Config(dir);
19
+ let backendConfig = explicitBackendConfig || config.getBackend();
20
+ this.workspaceManager = new workspace_1.WorkspaceManager(dir);
21
+ const currentWorkspace = this.workspaceManager.getCurrentWorkspace();
22
+ // If backend is explicitly disabled via CLI, force local
23
+ if (disableBackend) {
24
+ backendConfig = { type: 'local', config: {} };
25
+ }
26
+ else if (backendConfig && backendConfig.type !== 'local' && backendConfigRaw) {
27
+ // Apply CLI overrides to the backend config
28
+ const overrides = this.parseBackendOverrides(backendConfigRaw);
29
+ backendConfig.config = { ...backendConfig.config, ...overrides };
30
+ }
31
+ if (backendConfig?.type === 's3') {
32
+ this.backend = new s3_1.S3Backend(backendConfig.config, currentWorkspace);
33
+ }
34
+ else if (backendConfig?.type === 'azurerm') {
35
+ this.backend = new azurerm_1.AzureRMBackend(backendConfig.config, currentWorkspace);
36
+ }
37
+ else if (backendConfig?.type === 'gcs') {
38
+ this.backend = new gcs_1.GCSBackend(backendConfig.config, currentWorkspace);
39
+ }
40
+ else {
41
+ this.backend = new local_1.LocalBackend(dir, customStatePath, customBackupPath, currentWorkspace);
42
+ }
43
+ }
44
+ parseBackendOverrides(raw) {
45
+ const overrides = {};
46
+ const items = Array.isArray(raw) ? raw : [raw];
47
+ for (const item of items) {
48
+ const index = item.indexOf('=');
49
+ if (index > 0) {
50
+ const key = item.substring(0, index).trim();
51
+ const val = item.substring(index + 1).trim();
52
+ overrides[key] = val;
53
+ }
54
+ }
55
+ return overrides;
56
+ }
57
+ async hasState() {
58
+ return await this.backend.exists();
59
+ }
60
+ async init() {
61
+ const parsed = await this.backend.read();
62
+ // Validate version
63
+ if (!parsed.version) {
64
+ notify_1.notify.push({
65
+ type: 'error',
66
+ title: `${const_1.FILE_STATE} is missing the "version" field`,
67
+ detail: [`Add a "version" field to your ${const_1.FILE_STATE} file and rerun init.`],
68
+ close: true
69
+ });
70
+ }
71
+ else if (parsed.version !== const_1.VERSION_STATE) {
72
+ notify_1.notify.push({
73
+ type: 'error',
74
+ title: `state version mismatch`,
75
+ detail: [
76
+ `Found version "${parsed.version}", but expected "${const_1.VERSION_STATE}".`,
77
+ `Update ${const_1.FILE_STATE} to version ${const_1.VERSION_STATE} and rerun init.`,
78
+ ],
79
+ close: true
80
+ });
81
+ }
82
+ this.state = {
83
+ version: parsed.version || const_1.VERSION_STATE,
84
+ serial: parsed.serial ?? 0,
85
+ lineage: parsed.lineage ?? (0, crypto_1.randomUUID)(),
86
+ lastSync: parsed.lastSync ?? '',
87
+ resources: parsed.resources ?? [],
88
+ };
89
+ }
90
+ async acquireLock(enabled, timeoutRaw) {
91
+ if (!enabled)
92
+ return;
93
+ const success = await this.backend.lock(timeoutRaw);
94
+ if (!success) {
95
+ console.error(`\nError: Acquiring the state lock.\n\nTestform acquires a state lock to protect the state from being written\nby multiple users at the same time. Please resolve the issue above and try\nagain. For most commands, you can disable locking with the "-lock=false"\nflag, but this is not recommended.\n`);
96
+ process.exit(1);
97
+ }
98
+ const cleanup = async () => { await this.releaseLock(); process.exit(1); };
99
+ process.on('SIGINT', cleanup);
100
+ process.on('SIGTERM', cleanup);
101
+ // Using 'exit' with async is problematic, but we try to clean up synchronously if we can.
102
+ // Actually, we'll just handle clean exits gracefully via releaseLock().
103
+ }
104
+ async releaseLock() {
105
+ await this.backend.unlock();
106
+ }
107
+ async forceUnlock(lockId) {
108
+ return this.backend.forceUnlock(lockId);
109
+ }
110
+ /**
111
+ * Save state to disk or backend. Increments serial and updates lastSync.
112
+ */
113
+ async save() {
114
+ this.state.serial += 1;
115
+ this.state.lastSync = new Date().toISOString();
116
+ await this.backend.write(this.state);
117
+ }
118
+ /**
119
+ * Get current state (read-only snapshot).
120
+ */
121
+ getState() {
122
+ return this.state;
123
+ }
124
+ /**
125
+ * Replace the entire internal state with a new snapshot (useful for migration).
126
+ * Note: This does not automatically save to backend. Call .save() afterwards.
127
+ */
128
+ replaceState(newState) {
129
+ this.state = JSON.parse(JSON.stringify(newState)); // Deep copy to avoid reference issues
130
+ }
131
+ /**
132
+ * Get all resources of a given type.
133
+ */
134
+ getResources(type) {
135
+ if (!this.state.resources)
136
+ return [];
137
+ if (!type)
138
+ return this.state.resources;
139
+ return this.state.resources.filter(r => r.type === type);
140
+ }
141
+ // Workspace Delegation
142
+ getCurrentWorkspace() {
143
+ return this.workspaceManager.getCurrentWorkspace();
144
+ }
145
+ setCurrentWorkspace(name) {
146
+ this.workspaceManager.setCurrentWorkspace(name);
147
+ }
148
+ async listWorkspaces() {
149
+ return this.backend.listWorkspaces();
150
+ }
151
+ async deleteWorkspace(name) {
152
+ return this.backend.deleteWorkspace(name);
153
+ }
154
+ /**
155
+ * Find a resource by identity.
156
+ */
157
+ findResource(identity) {
158
+ return this.state.resources.find(r => r.identity === identity);
159
+ }
160
+ /**
161
+ * Add or update a resource after a successful apply operation.
162
+ */
163
+ upsertResource(resource) {
164
+ const idx = this.state.resources.findIndex(r => r.identity === resource.identity);
165
+ if (idx >= 0) {
166
+ this.state.resources[idx] = resource;
167
+ }
168
+ else {
169
+ this.state.resources.push(resource);
170
+ }
171
+ }
172
+ /**
173
+ * Remove a resource after a successful destroy operation.
174
+ */
175
+ removeResource(identity) {
176
+ this.state.resources = this.state.resources.filter(r => r.identity !== identity);
177
+ }
178
+ /**
179
+ * Clear all resources (used by full destroy).
180
+ */
181
+ clearResources() {
182
+ this.state.resources = [];
183
+ }
184
+ }
185
+ exports.State = State;