@microck/canonfig 2.0.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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -0
  3. package/dist/agent/agent-resolution.errors.js +42 -0
  4. package/dist/agent/agent-resolution.layer.js +204 -0
  5. package/dist/agent/agent-resolution.service.js +2259 -0
  6. package/dist/agent/agent-resolution.types.js +1 -0
  7. package/dist/agent/controlled-executor.js +704 -0
  8. package/dist/agent/harness-adapters.js +85 -0
  9. package/dist/cli/cli.js +618 -0
  10. package/dist/cli/exit-codes.js +28 -0
  11. package/dist/cli/follower-commands.js +3 -0
  12. package/dist/cli/render.js +56 -0
  13. package/dist/cli/source-commands.js +5 -0
  14. package/dist/domain/brand.js +29 -0
  15. package/dist/domain/identity.js +31 -0
  16. package/dist/domain/npm-package-spec.js +186 -0
  17. package/dist/domain/profile.js +950 -0
  18. package/dist/domain/recipe-versions.js +297 -0
  19. package/dist/domain/resource.js +259 -0
  20. package/dist/domain/synchronization.js +346 -0
  21. package/dist/enrollment/enrollment.errors.js +43 -0
  22. package/dist/enrollment/enrollment.layer.js +724 -0
  23. package/dist/enrollment/enrollment.service.js +3 -0
  24. package/dist/enrollment/enrollment.types.js +59 -0
  25. package/dist/enrollment/follower-client.js +585 -0
  26. package/dist/enrollment/source-server.js +313 -0
  27. package/dist/machine/linux.layer.js +1183 -0
  28. package/dist/machine/machine-state.errors.js +52 -0
  29. package/dist/machine/machine-state.service.js +3 -0
  30. package/dist/machine/machine-state.types.js +1 -0
  31. package/dist/machine/macos.layer.js +470 -0
  32. package/dist/machine/windows.layer.js +879 -0
  33. package/dist/profile/discovery.js +740 -0
  34. package/dist/profile/profile-catalog.errors.js +50 -0
  35. package/dist/profile/profile-catalog.layer.js +20 -0
  36. package/dist/profile/profile-catalog.service.js +7 -0
  37. package/dist/profile/profile-codec.js +153 -0
  38. package/dist/profile/publication.js +298 -0
  39. package/dist/profile/tool-catalog.js +384 -0
  40. package/dist/runtime/doctor.js +306 -0
  41. package/dist/runtime/layers.js +706 -0
  42. package/dist/runtime/main.js +38 -0
  43. package/dist/schedule/linux-schedule.js +24 -0
  44. package/dist/schedule/macos-schedule.js +25 -0
  45. package/dist/schedule/schedule-manager.errors.js +17 -0
  46. package/dist/schedule/schedule-manager.layer.js +205 -0
  47. package/dist/schedule/schedule-manager.service.js +3 -0
  48. package/dist/schedule/schedule-manager.types.js +114 -0
  49. package/dist/schedule/windows-schedule.js +25 -0
  50. package/dist/state/state-repository.errors.js +55 -0
  51. package/dist/state/state-repository.layer.js +1507 -0
  52. package/dist/state/state-repository.service.js +3 -0
  53. package/dist/state/state-repository.types.js +1 -0
  54. package/dist/state/state-schema.js +298 -0
  55. package/dist/synchronization/config-codec.js +97 -0
  56. package/dist/synchronization/executor.js +700 -0
  57. package/dist/synchronization/follower-orchestration.js +939 -0
  58. package/dist/synchronization/follower-sync-config.js +81 -0
  59. package/dist/synchronization/npm-artifact.js +670 -0
  60. package/dist/synchronization/planner.js +378 -0
  61. package/dist/synchronization/recovery.js +397 -0
  62. package/dist/synchronization/resource-executors.js +1198 -0
  63. package/dist/synchronization/resource-plans.js +645 -0
  64. package/dist/synchronization/synchronization.errors.js +102 -0
  65. package/dist/synchronization/synchronization.layer.js +97 -0
  66. package/dist/synchronization/synchronization.service.js +11 -0
  67. package/dist/synchronization/synchronization.types.js +1 -0
  68. package/package.json +66 -0
@@ -0,0 +1,3 @@
1
+ import { Context } from "effect";
2
+ export class StateRepository extends Context.Service()("canonfig/state/StateRepository") {
3
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,298 @@
1
+ import { Effect } from "effect";
2
+ import { SqliteMigrator } from "@effect/sql-sqlite-node";
3
+ import { SqlClient } from "effect/unstable/sql";
4
+ /**
5
+ * Explicit, append-only migration set. Migrations are tracked by Effect SQL,
6
+ * while each DDL statement remains idempotent for interrupted initialization.
7
+ */
8
+ export const stateMigrations = SqliteMigrator.fromRecord({
9
+ "0001_state_repository": Effect.gen(function* () {
10
+ const sql = yield* SqlClient.SqlClient;
11
+ yield* sql `PRAGMA foreign_keys = ON`;
12
+ yield* sql `
13
+ CREATE TABLE IF NOT EXISTS source_identity (
14
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
15
+ key_id TEXT NOT NULL,
16
+ public_key_fingerprint TEXT NOT NULL
17
+ )
18
+ `;
19
+ yield* sql `
20
+ CREATE TABLE IF NOT EXISTS followers (
21
+ id TEXT PRIMARY KEY,
22
+ name TEXT NOT NULL,
23
+ groups_json TEXT NOT NULL,
24
+ revoked INTEGER NOT NULL CHECK (revoked IN (0, 1)),
25
+ credential_reference TEXT NOT NULL,
26
+ enrolled_at TEXT NOT NULL
27
+ )
28
+ `;
29
+ yield* sql `
30
+ CREATE TABLE IF NOT EXISTS profile_revisions (
31
+ id TEXT PRIMARY KEY,
32
+ profile_id TEXT NOT NULL,
33
+ sequence INTEGER NOT NULL,
34
+ canonical_bytes TEXT NOT NULL,
35
+ digest TEXT NOT NULL,
36
+ signature TEXT NOT NULL,
37
+ published_at TEXT NOT NULL,
38
+ revision_json TEXT NOT NULL,
39
+ UNIQUE (profile_id, sequence)
40
+ )
41
+ `;
42
+ yield* sql `
43
+ CREATE TRIGGER IF NOT EXISTS profile_revisions_immutable_update
44
+ BEFORE UPDATE ON profile_revisions
45
+ BEGIN
46
+ SELECT RAISE(ABORT, 'profile revisions are immutable');
47
+ END
48
+ `;
49
+ yield* sql `
50
+ CREATE TRIGGER IF NOT EXISTS profile_revisions_immutable_delete
51
+ BEFORE DELETE ON profile_revisions
52
+ BEGIN
53
+ SELECT RAISE(ABORT, 'profile revisions are immutable');
54
+ END
55
+ `;
56
+ yield* sql `
57
+ CREATE TABLE IF NOT EXISTS synchronization_runs (
58
+ id TEXT PRIMARY KEY,
59
+ follower_id TEXT NOT NULL REFERENCES followers(id),
60
+ revision_id TEXT NOT NULL REFERENCES profile_revisions(id),
61
+ status TEXT NOT NULL CHECK (
62
+ status IN (
63
+ 'applying',
64
+ 'Converged',
65
+ 'HumanActionRequired',
66
+ 'FollowerDrift',
67
+ 'Failed',
68
+ 'Interrupted'
69
+ )
70
+ ),
71
+ plan_json TEXT NOT NULL,
72
+ started_at TEXT NOT NULL,
73
+ completed_at TEXT,
74
+ outcome_json TEXT
75
+ )
76
+ `;
77
+ yield* sql `
78
+ CREATE UNIQUE INDEX IF NOT EXISTS one_active_applying_run_per_follower
79
+ ON synchronization_runs(follower_id)
80
+ WHERE status = 'applying'
81
+ `;
82
+ yield* sql `
83
+ CREATE TABLE IF NOT EXISTS run_actions (
84
+ run_id TEXT NOT NULL REFERENCES synchronization_runs(id) ON DELETE RESTRICT,
85
+ action_id TEXT NOT NULL,
86
+ plan_ordinal INTEGER NOT NULL,
87
+ PRIMARY KEY (run_id, action_id),
88
+ UNIQUE (run_id, plan_ordinal)
89
+ )
90
+ `;
91
+ yield* sql `
92
+ CREATE TABLE IF NOT EXISTS action_journal (
93
+ id INTEGER PRIMARY KEY,
94
+ run_id TEXT NOT NULL,
95
+ action_id TEXT NOT NULL,
96
+ sequence INTEGER NOT NULL,
97
+ state TEXT NOT NULL CHECK (
98
+ state IN ('pending', 'running', 'succeeded', 'failed', 'skipped')
99
+ ),
100
+ recorded_at TEXT NOT NULL,
101
+ attempt INTEGER NOT NULL CHECK (attempt >= 0),
102
+ verification_json TEXT,
103
+ rollback_reference TEXT,
104
+ FOREIGN KEY (run_id, action_id)
105
+ REFERENCES run_actions(run_id, action_id),
106
+ UNIQUE (run_id, sequence)
107
+ )
108
+ `;
109
+ yield* sql `
110
+ CREATE TABLE IF NOT EXISTS drift_records (
111
+ id INTEGER PRIMARY KEY,
112
+ run_id TEXT NOT NULL REFERENCES synchronization_runs(id),
113
+ sequence INTEGER NOT NULL,
114
+ conflict_json TEXT NOT NULL,
115
+ recorded_at TEXT NOT NULL,
116
+ UNIQUE (run_id, sequence)
117
+ )
118
+ `;
119
+ yield* sql `
120
+ CREATE TABLE IF NOT EXISTS applied_resources (
121
+ follower_id TEXT NOT NULL REFERENCES followers(id),
122
+ resource_id TEXT NOT NULL,
123
+ revision_id TEXT NOT NULL REFERENCES profile_revisions(id),
124
+ digest TEXT NOT NULL,
125
+ applied_at TEXT NOT NULL,
126
+ PRIMARY KEY (follower_id, resource_id)
127
+ )
128
+ `;
129
+ }),
130
+ "0002_enrollment": Effect.gen(function* () {
131
+ const sql = yield* SqlClient.SqlClient;
132
+ yield* sql `
133
+ CREATE TABLE IF NOT EXISTS enrollment_source (
134
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
135
+ signing_key_reference TEXT NOT NULL,
136
+ tls_key_reference TEXT NOT NULL,
137
+ tls_certificate_reference TEXT NOT NULL,
138
+ tls_fingerprint TEXT NOT NULL
139
+ )
140
+ `;
141
+ yield* sql `
142
+ CREATE TABLE IF NOT EXISTS enrollment_invitations (
143
+ code_digest TEXT PRIMARY KEY,
144
+ nonce_digest TEXT NOT NULL UNIQUE,
145
+ intended_source_fingerprint TEXT NOT NULL,
146
+ tls_fingerprint TEXT NOT NULL,
147
+ endpoint TEXT NOT NULL,
148
+ groups_json TEXT NOT NULL,
149
+ expires_at TEXT NOT NULL,
150
+ used_at TEXT
151
+ )
152
+ `;
153
+ yield* sql `
154
+ CREATE TABLE IF NOT EXISTS follower_credentials (
155
+ follower_id TEXT PRIMARY KEY REFERENCES followers(id) ON DELETE RESTRICT,
156
+ credential_digest TEXT NOT NULL UNIQUE,
157
+ credential_reference TEXT NOT NULL UNIQUE
158
+ )
159
+ `;
160
+ }),
161
+ "0003_follower_synchronization_configuration": Effect.gen(function* () {
162
+ const sql = yield* SqlClient.SqlClient;
163
+ yield* sql `
164
+ CREATE TABLE IF NOT EXISTS follower_sync_configuration (
165
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
166
+ follower_id TEXT NOT NULL REFERENCES followers(id) ON DELETE RESTRICT,
167
+ configuration_json TEXT NOT NULL,
168
+ updated_at TEXT NOT NULL
169
+ )
170
+ `;
171
+ }),
172
+ "0004_applied_resource_owned_files": Effect.gen(function* () {
173
+ const sql = yield* SqlClient.SqlClient;
174
+ yield* sql `
175
+ ALTER TABLE applied_resources
176
+ ADD COLUMN owned_files_json TEXT
177
+ `;
178
+ }),
179
+ "0005_applied_resource_schedule": Effect.gen(function* () {
180
+ const sql = yield* SqlClient.SqlClient;
181
+ yield* sql `
182
+ ALTER TABLE applied_resources
183
+ ADD COLUMN schedule_json TEXT
184
+ `;
185
+ }),
186
+ "0006_applied_resource_removal_ownership": Effect.gen(function* () {
187
+ const sql = yield* SqlClient.SqlClient;
188
+ yield* sql `
189
+ ALTER TABLE applied_resources
190
+ ADD COLUMN kind TEXT
191
+ `;
192
+ yield* sql `
193
+ ALTER TABLE applied_resources
194
+ ADD COLUMN policy TEXT
195
+ `;
196
+ yield* sql `
197
+ ALTER TABLE applied_resources
198
+ ADD COLUMN target TEXT
199
+ `;
200
+ yield* sql `
201
+ ALTER TABLE applied_resources
202
+ ADD COLUMN owned_keys_json TEXT
203
+ `;
204
+ yield* sql `
205
+ ALTER TABLE applied_resources
206
+ ADD COLUMN config_format TEXT
207
+ `;
208
+ }),
209
+ "0007_applied_resource_file_ownership": Effect.gen(function* () {
210
+ const sql = yield* SqlClient.SqlClient;
211
+ yield* sql `
212
+ ALTER TABLE applied_resources
213
+ ADD COLUMN executable INTEGER
214
+ `;
215
+ yield* sql `
216
+ ALTER TABLE applied_resources
217
+ ADD COLUMN symlink_target TEXT
218
+ `;
219
+ }),
220
+ "0008_action_journal_removed_resource": Effect.gen(function* () {
221
+ const sql = yield* SqlClient.SqlClient;
222
+ yield* sql `
223
+ ALTER TABLE action_journal
224
+ ADD COLUMN removed_resource_json TEXT
225
+ `;
226
+ }),
227
+ "0009_recoverable_run_blocks_new_runs": Effect.gen(function* () {
228
+ const sql = yield* SqlClient.SqlClient;
229
+ // Interrupted runs retain rollback and journal evidence and are therefore
230
+ // still recoverable work, not terminal history. Keep the original unique
231
+ // index for concurrent applying inserts and add a durable guard for
232
+ // interrupted recovery. This remains migratable even when older databases
233
+ // already contain more than one historical Interrupted row.
234
+ yield* sql `
235
+ CREATE INDEX IF NOT EXISTS recoverable_runs_by_follower
236
+ ON synchronization_runs(follower_id, status)
237
+ WHERE status IN ('applying', 'Interrupted')
238
+ `;
239
+ yield* sql `
240
+ CREATE TRIGGER IF NOT EXISTS block_run_while_recoverable
241
+ BEFORE INSERT ON synchronization_runs
242
+ WHEN EXISTS (
243
+ SELECT 1
244
+ FROM synchronization_runs
245
+ WHERE follower_id = NEW.follower_id
246
+ AND status IN ('applying', 'Interrupted')
247
+ )
248
+ BEGIN
249
+ SELECT RAISE(ABORT, 'recoverable synchronization run exists');
250
+ END
251
+ `;
252
+ }),
253
+ "0010_transactional_enrollment_and_local_overlays": Effect.gen(function* () {
254
+ const sql = yield* SqlClient.SqlClient;
255
+ yield* sql `
256
+ CREATE TABLE IF NOT EXISTS pending_enrollments (
257
+ follower_id TEXT PRIMARY KEY,
258
+ code_digest TEXT NOT NULL UNIQUE,
259
+ credential_digest TEXT NOT NULL UNIQUE,
260
+ credential_reference TEXT NOT NULL UNIQUE,
261
+ follower_json TEXT NOT NULL,
262
+ created_at TEXT NOT NULL
263
+ )
264
+ `;
265
+ }),
266
+ "0011_profile_revision_blob_index": Effect.gen(function* () {
267
+ const sql = yield* SqlClient.SqlClient;
268
+ yield* sql `
269
+ CREATE TABLE IF NOT EXISTS profile_revision_blobs (
270
+ blob_id TEXT NOT NULL,
271
+ revision_id TEXT NOT NULL REFERENCES profile_revisions(id) ON DELETE CASCADE,
272
+ resource_id TEXT NOT NULL,
273
+ PRIMARY KEY (blob_id, revision_id, resource_id)
274
+ )
275
+ `;
276
+ yield* sql `
277
+ CREATE INDEX IF NOT EXISTS profile_revision_blobs_by_blob
278
+ ON profile_revision_blobs(blob_id, revision_id, resource_id)
279
+ `;
280
+ // Backfill the derived lookup for databases that already contain
281
+ // immutable revisions. The signed revision remains the source of truth;
282
+ // this table only narrows the set of candidates that must be verified.
283
+ yield* sql `
284
+ INSERT OR IGNORE INTO profile_revision_blobs (blob_id, revision_id, resource_id)
285
+ SELECT
286
+ blob.value,
287
+ profile_revisions.id,
288
+ json_extract(resource.value, '$.id')
289
+ FROM profile_revisions
290
+ CROSS JOIN json_each(
291
+ json_extract(profile_revisions.revision_json, '$.resources')
292
+ ) AS resource
293
+ CROSS JOIN json_each(
294
+ json_extract(resource.value, '$.blobs')
295
+ ) AS blob
296
+ `;
297
+ }),
298
+ });
@@ -0,0 +1,97 @@
1
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
2
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
3
+ import { Option, Schema } from "effect";
4
+ const ConfigDocumentSchema = Schema.Record(Schema.String, Schema.MutableJson);
5
+ export const parseConfigDocument = (format, text) => {
6
+ switch (format) {
7
+ case "json": {
8
+ const decoded = Schema.decodeUnknownSync(ConfigDocumentSchema)(JSON.parse(text));
9
+ return { ...decoded };
10
+ }
11
+ case "toml": {
12
+ const decoded = Schema.decodeUnknownSync(ConfigDocumentSchema)(parseToml(text));
13
+ return { ...decoded };
14
+ }
15
+ case "yaml": {
16
+ const decoded = Schema.decodeUnknownSync(ConfigDocumentSchema)(parseYaml(text));
17
+ return { ...decoded };
18
+ }
19
+ }
20
+ };
21
+ export const serializeConfigDocument = (format, document) => {
22
+ switch (format) {
23
+ case "json":
24
+ return `${JSON.stringify(document, undefined, 2)}\n`;
25
+ case "toml":
26
+ return stringifyToml(document);
27
+ case "yaml":
28
+ return stringifyYaml(document);
29
+ }
30
+ };
31
+ const pathSegments = (path) => {
32
+ const segments = path.split(".");
33
+ if (segments.some((segment) => segment.length === 0)) {
34
+ throw new TypeError(`config key path contains an empty segment: ${path}`);
35
+ }
36
+ return segments;
37
+ };
38
+ export const setConfigPath = (document, path, value) => {
39
+ const segments = pathSegments(path);
40
+ const parsedValue = Schema.decodeUnknownSync(Schema.MutableJson)(value);
41
+ let parent = document;
42
+ for (const segment of segments.slice(0, -1)) {
43
+ const child = parent[segment];
44
+ if (child === undefined) {
45
+ const created = {};
46
+ parent[segment] = created;
47
+ parent = created;
48
+ continue;
49
+ }
50
+ const nested = Option.getOrUndefined(Schema.decodeUnknownOption(ConfigDocumentSchema)(child));
51
+ if (nested === undefined) {
52
+ throw new TypeError(`config key path crosses a non-object value: ${path}`);
53
+ }
54
+ const mutableNested = { ...nested };
55
+ parent[segment] = mutableNested;
56
+ parent = mutableNested;
57
+ }
58
+ parent[segments.at(-1)] = parsedValue;
59
+ };
60
+ export const getConfigPath = (document, path) => {
61
+ const segments = pathSegments(path);
62
+ let value = document;
63
+ for (const segment of segments) {
64
+ const parent = Option.getOrUndefined(Schema.decodeUnknownOption(ConfigDocumentSchema)(value));
65
+ if (parent === undefined)
66
+ return undefined;
67
+ value = parent[segment];
68
+ if (value === undefined)
69
+ return undefined;
70
+ }
71
+ return value;
72
+ };
73
+ export const removeConfigPath = (document, path) => {
74
+ const segments = pathSegments(path);
75
+ const parents = [];
76
+ let current = document;
77
+ for (const segment of segments.slice(0, -1)) {
78
+ const nested = Option.getOrUndefined(Schema.decodeUnknownOption(ConfigDocumentSchema)(current[segment]));
79
+ if (nested === undefined)
80
+ return;
81
+ parents.push({ document: current, segment });
82
+ const mutableNested = { ...nested };
83
+ current[segment] = mutableNested;
84
+ current = mutableNested;
85
+ }
86
+ delete current[segments.at(-1)];
87
+ for (const parent of parents.reverse()) {
88
+ const child = parent.document[parent.segment];
89
+ const nested = child === undefined
90
+ ? undefined
91
+ : Option.getOrUndefined(Schema.decodeUnknownOption(ConfigDocumentSchema)(child));
92
+ if (nested !== undefined
93
+ && Object.keys(nested).length === 0) {
94
+ delete parent.document[parent.segment];
95
+ }
96
+ }
97
+ };