@illuminis/comprism 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.
Files changed (80) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +281 -0
  3. package/out/agent/command.d.ts +86 -0
  4. package/out/agent/command.js +259 -0
  5. package/out/agent/render.d.ts +97 -0
  6. package/out/agent/render.js +255 -0
  7. package/out/agent/session.d.ts +175 -0
  8. package/out/agent/session.js +573 -0
  9. package/out/commands/ask.d.ts +1 -0
  10. package/out/commands/ask.js +146 -0
  11. package/out/commands/codemap.d.ts +2 -0
  12. package/out/commands/codemap.js +151 -0
  13. package/out/commands/commands-thin.d.ts +39 -0
  14. package/out/commands/commands-thin.js +182 -0
  15. package/out/commands/install.d.ts +163 -0
  16. package/out/commands/install.js +543 -0
  17. package/out/commands/keys.d.ts +55 -0
  18. package/out/commands/keys.js +344 -0
  19. package/out/commands/login.d.ts +9 -0
  20. package/out/commands/login.js +384 -0
  21. package/out/commands/repl.d.ts +1 -0
  22. package/out/commands/repl.js +752 -0
  23. package/out/commands/settings.d.ts +21 -0
  24. package/out/commands/settings.js +244 -0
  25. package/out/commands/welcome.d.ts +1 -0
  26. package/out/commands/welcome.js +196 -0
  27. package/out/executor/documents.d.ts +40 -0
  28. package/out/executor/documents.js +170 -0
  29. package/out/executor/files.d.ts +2 -0
  30. package/out/executor/files.js +360 -0
  31. package/out/executor/git.d.ts +48 -0
  32. package/out/executor/git.js +132 -0
  33. package/out/executor/hooks.d.ts +67 -0
  34. package/out/executor/hooks.js +247 -0
  35. package/out/executor/index.d.ts +29 -0
  36. package/out/executor/index.js +221 -0
  37. package/out/executor/notebook.d.ts +2 -0
  38. package/out/executor/notebook.js +147 -0
  39. package/out/executor/paths.d.ts +15 -0
  40. package/out/executor/paths.js +126 -0
  41. package/out/executor/shell.d.ts +41 -0
  42. package/out/executor/shell.js +336 -0
  43. package/out/graph/build.d.ts +45 -0
  44. package/out/graph/build.js +91 -0
  45. package/out/graph/facts.d.ts +47 -0
  46. package/out/graph/facts.js +12 -0
  47. package/out/graph/files.d.ts +45 -0
  48. package/out/graph/files.js +207 -0
  49. package/out/graph/read-locales.d.ts +29 -0
  50. package/out/graph/read-locales.js +246 -0
  51. package/out/graph/read-python.d.ts +11 -0
  52. package/out/graph/read-python.js +115 -0
  53. package/out/graph/read-typescript.d.ts +16 -0
  54. package/out/graph/read-typescript.js +292 -0
  55. package/out/graph/sync.d.ts +66 -0
  56. package/out/graph/sync.js +242 -0
  57. package/out/lib/attach.d.ts +62 -0
  58. package/out/lib/attach.js +228 -0
  59. package/out/lib/config.d.ts +93 -0
  60. package/out/lib/config.js +198 -0
  61. package/out/lib/connection.d.ts +73 -0
  62. package/out/lib/connection.js +188 -0
  63. package/out/lib/gateway.d.ts +239 -0
  64. package/out/lib/gateway.js +171 -0
  65. package/out/lib/prompt.d.ts +34 -0
  66. package/out/lib/prompt.js +108 -0
  67. package/out/lib/types.d.ts +417 -0
  68. package/out/lib/types.js +21 -0
  69. package/out/lib/ui.d.ts +114 -0
  70. package/out/lib/ui.js +265 -0
  71. package/out/lib/version.d.ts +24 -0
  72. package/out/lib/version.js +27 -0
  73. package/out/lib/voice.d.ts +50 -0
  74. package/out/lib/voice.js +218 -0
  75. package/out/postinstall.d.ts +2 -0
  76. package/out/postinstall.js +92 -0
  77. package/out/thin.d.ts +2 -0
  78. package/out/thin.js +259 -0
  79. package/package.json +101 -0
  80. package/scripts/read_python.py +270 -0
@@ -0,0 +1,198 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.HOME_DIR_NAME = void 0;
7
+ exports.homeDir = homeDir;
8
+ exports.ensureHome = ensureHome;
9
+ exports.configPath = configPath;
10
+ exports.defaultConfig = defaultConfig;
11
+ exports.loadConfig = loadConfig;
12
+ exports.saveConfig = saveConfig;
13
+ exports.resolveKeys = resolveKeys;
14
+ exports.availableProviders = availableProviders;
15
+ exports.keySource = keySource;
16
+ exports.envVarName = envVarName;
17
+ /**
18
+ * Home directory, configuration and key resolution.
19
+ *
20
+ * ── Keys are never written. Anywhere. ──────────────────────────────────────
21
+ *
22
+ * The frozen spec (0.6) says keys resolve "from env first, then config file".
23
+ * This implementation keeps the first half and drops the second, deliberately,
24
+ * and it is a strengthening rather than a simplification: nothing about the
25
+ * mechanism changes, one storage location is removed.
26
+ *
27
+ * The reason is that Phase 1's acceptance list contains an absolute canary -
28
+ * "keys never appear in any file or log". A config file that may hold a key
29
+ * makes that test conditional on which file you look in, and a security
30
+ * property that holds in most files is not a security property. So:
31
+ *
32
+ * 1. `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` from the environment, or
33
+ * 2. a `keyCommand` the user configures - a shell command that PRINTS the key
34
+ * (`op read ...`, `aws secretsmanager get-secret-value ...`). The command
35
+ * is stored; its output never is.
36
+ *
37
+ * That covers the case the config-file fallback existed for - a user who does
38
+ * not want long-lived secrets in their shell profile - without the Companion
39
+ * ever holding a secret at rest.
40
+ */
41
+ const node_fs_1 = __importDefault(require("node:fs"));
42
+ const node_os_1 = __importDefault(require("node:os"));
43
+ const node_path_1 = __importDefault(require("node:path"));
44
+ exports.HOME_DIR_NAME = '.comprism';
45
+ function homeDir() {
46
+ return process.env.COMPRISM_HOME || node_path_1.default.join(node_os_1.default.homedir(), exports.HOME_DIR_NAME);
47
+ }
48
+ /**
49
+ * Create the home directory at mode 700 if it is not there.
50
+ *
51
+ * 700 rather than the default 755: other local accounts have no business
52
+ * reading someone's work records, and a directory created world-readable is
53
+ * never noticed afterwards.
54
+ */
55
+ function ensureHome() {
56
+ const dir = homeDir();
57
+ node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
58
+ try {
59
+ node_fs_1.default.chmodSync(dir, 0o700);
60
+ }
61
+ catch {
62
+ /* a filesystem that cannot chmod is not a reason to fail a request */
63
+ }
64
+ return dir;
65
+ }
66
+ function configPath() {
67
+ return node_path_1.default.join(homeDir(), 'config.json');
68
+ }
69
+ function defaultConfig() {
70
+ const now = new Date().toISOString();
71
+ return {
72
+ config_version: 1,
73
+ user: node_os_1.default.userInfo().username,
74
+ mode: 'monitor',
75
+ providers: {
76
+ anthropic: { enabled: true },
77
+ openai: { enabled: true },
78
+ },
79
+ allowedModels: [],
80
+ // Balanced and capable. The band is still recorded on every decision; it
81
+ // simply does not drive dispatch while the estimator has nothing to say.
82
+ defaultModel: 'claude-sonnet-5',
83
+ // ON by default, and the customer turns it off. Their work lives on our
84
+ // servers exactly as their other business data lives on a vendor's, and a
85
+ // product that cannot see the work cannot show them what finishing it cost
86
+ // or improve at predicting it. Opting out is one setting on their account,
87
+ // not a request they have to make of us.
88
+ contentPolicy: { storeText: true },
89
+ context: {},
90
+ proxyPort: 8787,
91
+ footer: {
92
+ enabled: true,
93
+ template: 'completion',
94
+ // `auto` is the next capability band up on the same provider - the
95
+ // comparison a person actually makes. A tenant that has mandated one
96
+ // model everywhere names it here instead.
97
+ baselineModel: 'auto',
98
+ tenant: '',
99
+ // Production by default. Localhost is opt-in for development via
100
+ // COMPRISM_DEV=1, so a fresh install talks to the real tenant and a
101
+ // receipt's link goes somewhere a person can actually open.
102
+ portal: {
103
+ useDev: process.env.COMPRISM_DEV === '1',
104
+ devUrl: 'http://localhost:5182',
105
+ domain: 'completionprism.illuminis.ai',
106
+ },
107
+ pricing: {},
108
+ },
109
+ created: now,
110
+ updated: now,
111
+ };
112
+ }
113
+ function loadConfig() {
114
+ const p = configPath();
115
+ if (!node_fs_1.default.existsSync(p))
116
+ return defaultConfig();
117
+ try {
118
+ const raw = JSON.parse(node_fs_1.default.readFileSync(p, 'utf8'));
119
+ const base = defaultConfig();
120
+ return {
121
+ ...base,
122
+ ...raw,
123
+ providers: { ...base.providers, ...(raw.providers || {}) },
124
+ contentPolicy: { ...base.contentPolicy, ...(raw.contentPolicy || {}) },
125
+ context: { ...base.context, ...(raw.context || {}) },
126
+ footer: {
127
+ ...base.footer,
128
+ ...(raw.footer || {}),
129
+ // Merged rather than replaced: a tenant who states their negotiated
130
+ // price for one model still gets the catalog for the rest.
131
+ pricing: { ...base.footer.pricing, ...(raw.footer?.pricing || {}) },
132
+ },
133
+ };
134
+ }
135
+ catch {
136
+ // A corrupt config must not stop someone working. Fail open to defaults.
137
+ return defaultConfig();
138
+ }
139
+ }
140
+ function saveConfig(cfg) {
141
+ ensureHome();
142
+ const out = { ...cfg, updated: new Date().toISOString() };
143
+ const p = configPath();
144
+ node_fs_1.default.writeFileSync(p, `${JSON.stringify(out, null, 2)}\n`, { mode: 0o600 });
145
+ }
146
+ const ENV_VAR = {
147
+ anthropic: 'ANTHROPIC_API_KEY',
148
+ openai: 'OPENAI_API_KEY',
149
+ };
150
+ /**
151
+ * Resolve keys for every enabled provider. Environment first, then the
152
+ * configured key command. A provider whose key does not resolve is simply not
153
+ * available; that is not an error until something tries to call it.
154
+ */
155
+ function resolveKeys(cfg) {
156
+ const keys = {};
157
+ for (const provider of Object.keys(ENV_VAR)) {
158
+ if (!cfg.providers[provider]?.enabled)
159
+ continue;
160
+ const fromEnv = process.env[ENV_VAR[provider]];
161
+ if (fromEnv && fromEnv.trim()) {
162
+ keys[provider] = fromEnv.trim();
163
+ continue;
164
+ }
165
+ // NO SHELL COMMAND IS RUN TO FETCH A KEY.
166
+ //
167
+ // A `keyCommand` was a stored shell line that printed a vendor credential,
168
+ // and the tool used to write one automatically after importing keys from a
169
+ // file. Running it meant the machine went and fetched a secret locally on
170
+ // every call, which is the behavior the owner ruled out on 17 September
171
+ // 2026, and it meant a settings file on a laptop contained a working
172
+ // instruction for obtaining somebody's vendor credentials.
173
+ //
174
+ // An existing `keyCommand` left over from an older copy of the tool is now
175
+ // simply ignored rather than obeyed. The field stays in the type so that
176
+ // reading an older settings file does not fail; nothing acts on it.
177
+ //
178
+ // What remains here is the environment variable, and it has exactly ONE
179
+ // job: DISCOVERY, so `comprism keys` can offer to store a key the person
180
+ // already has when their ACCOUNT has none. It is never the answer to
181
+ // "does this account have a key", and it is never sent with a question.
182
+ }
183
+ return keys;
184
+ }
185
+ function availableProviders(keys) {
186
+ return Object.keys(keys).filter((p) => !!keys[p]);
187
+ }
188
+ /** Where the key for a provider would come from, for `setup` to report. */
189
+ function keySource(cfg, provider) {
190
+ if (process.env[ENV_VAR[provider]]?.trim())
191
+ return 'env';
192
+ if (cfg.providers[provider]?.keyCommand?.trim())
193
+ return 'command';
194
+ return 'none';
195
+ }
196
+ function envVarName(provider) {
197
+ return ENV_VAR[provider];
198
+ }
@@ -0,0 +1,73 @@
1
+ export interface Connection {
2
+ /** Tenant base URL, e.g. https://acme.completionprism.ai */
3
+ url: string;
4
+ /** Our own session token for that tenant. Never a provider key. */
5
+ token: string;
6
+ /**
7
+ * The workspace key this machine presents on every intercepted request.
8
+ *
9
+ * A different object from `token` and deliberately so. The session token is
10
+ * short-lived and belongs to a browser sign-in; this is a long-lived machine
11
+ * credential minted for this laptop, listed in the portal beside every other
12
+ * machine, and revocable on its own without signing anybody out. Without one,
13
+ * the tenant refuses interception - so the install writes NO base URL at all
14
+ * rather than pointing tools at a door that will turn them away.
15
+ */
16
+ workspaceKey?: string;
17
+ /** Only needed on localhost, where there is no subdomain to resolve. */
18
+ tenant?: string;
19
+ /** Minutes between unattended syncs. 0 turns automatic sync off. */
20
+ everyMinutes: number;
21
+ /** When a sync last succeeded, so `status` can say how fresh the portal is. */
22
+ lastSyncedAt?: string;
23
+ /** The last failure, kept so a quiet problem is still a visible one. */
24
+ lastError?: string;
25
+ connectedAt: string;
26
+ }
27
+ export declare function connectionPath(): string;
28
+ export declare function readConnection(): Connection | null;
29
+ /**
30
+ * Is this address a machine on somebody's desk rather than a workspace.
31
+ *
32
+ * Loopback, the link-local range, and the three private network ranges. A
33
+ * hostname that is not an address at all is a workspace by definition, because
34
+ * nobody's laptop is reachable by name from anywhere else.
35
+ */
36
+ export declare function isLocalAddress(url: string): boolean;
37
+ /**
38
+ * The fence, and it sits at the moment of WRITING rather than of reading.
39
+ *
40
+ * ── The incident ──────────────────────────────────────────────────────────
41
+ *
42
+ * On 17 September 2026 a developer's own machine had this file pointing at
43
+ * `http://localhost:8009`. Nothing listened there, so every call failed, and
44
+ * the product told him his account had no provider keys, no plan and no name.
45
+ * Four keys were stored and active the whole time.
46
+ *
47
+ * Two separate mechanisms put that address there, and either alone is enough to
48
+ * do it again: the workspace lookup preferred a laptop when a development
49
+ * environment variable was set, and a development server answers the "where do
50
+ * I sign in" question with its own laptop address, which the tool then believed.
51
+ *
52
+ * ── Why the fence is here and not at every read ───────────────────────────
53
+ *
54
+ * The bad address enters ONCE and is then believed for ever. Checking at read
55
+ * time means checking in eleven places and missing one. Checking here means a
56
+ * server's answer can never install a laptop address, whatever the server says,
57
+ * and a person who genuinely wants one has to say so in the same breath.
58
+ *
59
+ * `typed` is true only when the person put the address on the command line in
60
+ * this same run. Not an environment variable, not a stored file, not a server's
61
+ * reply. Those are the three routes that produced the fault.
62
+ */
63
+ export declare function writeConnection(connection: Connection, typed?: boolean): void;
64
+ export declare function clearConnection(): boolean;
65
+ /** Record the outcome of a sync without disturbing anything else. */
66
+ export declare function noteSync(ok: boolean, error?: string): void;
67
+ /**
68
+ * Why the last enrollment attempt failed, in the tenant's own words.
69
+ *
70
+ * A module-level value rather than a thrown error, because failing to enrol is
71
+ * NOT a failure to sign in: the person is signed in either way, and the
72
+ * difference is only whether this machine can ask for work yet.
73
+ */
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.connectionPath = connectionPath;
7
+ exports.readConnection = readConnection;
8
+ exports.isLocalAddress = isLocalAddress;
9
+ exports.writeConnection = writeConnection;
10
+ exports.clearConnection = clearConnection;
11
+ exports.noteSync = noteSync;
12
+ /**
13
+ * The tenant connection: told to us once, then never asked for again.
14
+ *
15
+ * ── Why this file exists ──────────────────────────────────────────────────
16
+ *
17
+ * Sync started life as a command you ran by hand with a URL and a token on the
18
+ * line. That is defensible for a one-off and indefensible as a product: it
19
+ * makes the person the transport. Work recorded on a laptop only reaches the
20
+ * portal if somebody remembers to type something, so the portal is permanently
21
+ * behind, and "why is my session not showing up" becomes the commonest question
22
+ * about a product whose entire pitch is completeness.
23
+ *
24
+ * So the connection is stored once, and the recorder carries the records itself.
25
+ *
26
+ * ── What is stored, and the line that is not crossed ──────────────────────
27
+ *
28
+ * The endpoint, the tenant and OUR OWN session token, in a 0600 file inside the
29
+ * 0700 home directory. That is a different object from a provider API key, and
30
+ * the distinction is deliberate rather than convenient:
31
+ *
32
+ * - A provider key (Anthropic, OpenAI) is the customer's money. It is never
33
+ * written to disk by this product, in any file, ever. It is resolved from
34
+ * the environment or a key command at the moment it is needed.
35
+ * - A tenant token is a credential for OUR api, scoped to one tenant, held by
36
+ * the same person who owns the records it carries, and revocable by them.
37
+ * Storing it is what makes unattended sync possible at all.
38
+ *
39
+ * An expired token is not worth interrupting anyone over: the recorder keeps
40
+ * recording, the local store keeps its evidence, and the next `comprism connect`
41
+ * catches everything up. Nothing is lost by a failed sync, because sync is
42
+ * additive and never deletes the local record.
43
+ */
44
+ const node_fs_1 = __importDefault(require("node:fs"));
45
+ const node_path_1 = __importDefault(require("node:path"));
46
+ const config_1 = require("./config");
47
+ function connectionPath() {
48
+ return node_path_1.default.join((0, config_1.homeDir)(), 'connection.json');
49
+ }
50
+ function readConnection() {
51
+ try {
52
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(connectionPath(), 'utf8'));
53
+ // A machine enrolled by the one-line installer has a workspace key and no
54
+ // session token, and that is a complete, usable connection: it can be
55
+ // intercepted and measured. Requiring a token here silently discarded it.
56
+ return parsed.url && (parsed.token || parsed.workspaceKey) ? parsed : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * Is this address a machine on somebody's desk rather than a workspace.
64
+ *
65
+ * Loopback, the link-local range, and the three private network ranges. A
66
+ * hostname that is not an address at all is a workspace by definition, because
67
+ * nobody's laptop is reachable by name from anywhere else.
68
+ */
69
+ function isLocalAddress(url) {
70
+ let host;
71
+ try {
72
+ host = new URL(url).hostname.toLowerCase();
73
+ }
74
+ catch {
75
+ // UNPARSEABLE COUNTS AS LOCAL.
76
+ //
77
+ // This returned false, which meant anything the URL parser could not read
78
+ // was waved through as a workspace. `localhost:8009` with no scheme is
79
+ // exactly such a string, and it is the form our own help text suggests. A
80
+ // fence that opens when it cannot see is not a fence.
81
+ return true;
82
+ }
83
+ // `new URL` keeps IPv6 hosts in brackets. Strip them before comparing.
84
+ if (host.startsWith('[') && host.endsWith(']'))
85
+ host = host.slice(1, -1);
86
+ if (!host)
87
+ return true;
88
+ if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.localhost'))
89
+ return true;
90
+ if (host === '::1' || host === '0:0:0:0:0:0:0:1' || host === '::')
91
+ return true;
92
+ // Unique local addresses, fc00::/7. Both halves of the range.
93
+ if (/^f[cd][0-9a-f]{0,2}:/.test(host))
94
+ return true;
95
+ // IPv4-mapped IPv6, ::ffff:127.0.0.1, so the v4 rules below still apply.
96
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(host);
97
+ if (mapped && mapped[1])
98
+ host = mapped[1];
99
+ // Every dotted form node accepts, including the short ones: `127.1` is a
100
+ // loopback address and reads as a hostname to a naive four-part pattern.
101
+ const parts = host.split('.');
102
+ if (parts.length < 2 || parts.length > 4)
103
+ return false;
104
+ if (!parts.every((p) => /^\d{1,10}$/.test(p)))
105
+ return false;
106
+ const a = Number(parts[0] ?? '');
107
+ const b = parts.length >= 3 ? Number(parts[1] ?? '') : 0;
108
+ return a === 0 // 0.0.0.0 and anything in 0/8
109
+ || a === 127 // loopback, including 127.1
110
+ || a === 10 // private
111
+ || (a === 172 && b >= 16 && b <= 31) // private
112
+ || (a === 192 && b === 168) // private
113
+ || (a === 169 && b === 254); // link-local
114
+ }
115
+ /**
116
+ * The fence, and it sits at the moment of WRITING rather than of reading.
117
+ *
118
+ * ── The incident ──────────────────────────────────────────────────────────
119
+ *
120
+ * On 17 September 2026 a developer's own machine had this file pointing at
121
+ * `http://localhost:8009`. Nothing listened there, so every call failed, and
122
+ * the product told him his account had no provider keys, no plan and no name.
123
+ * Four keys were stored and active the whole time.
124
+ *
125
+ * Two separate mechanisms put that address there, and either alone is enough to
126
+ * do it again: the workspace lookup preferred a laptop when a development
127
+ * environment variable was set, and a development server answers the "where do
128
+ * I sign in" question with its own laptop address, which the tool then believed.
129
+ *
130
+ * ── Why the fence is here and not at every read ───────────────────────────
131
+ *
132
+ * The bad address enters ONCE and is then believed for ever. Checking at read
133
+ * time means checking in eleven places and missing one. Checking here means a
134
+ * server's answer can never install a laptop address, whatever the server says,
135
+ * and a person who genuinely wants one has to say so in the same breath.
136
+ *
137
+ * `typed` is true only when the person put the address on the command line in
138
+ * this same run. Not an environment variable, not a stored file, not a server's
139
+ * reply. Those are the three routes that produced the fault.
140
+ */
141
+ function writeConnection(connection, typed = false) {
142
+ if (!typed && isLocalAddress(connection.url)) {
143
+ throw new Error(`refusing to point this machine at ${connection.url}, which is this computer `
144
+ + 'rather than a workspace. Pass it with --workspace if that is genuinely what you want.');
145
+ }
146
+ (0, config_1.ensureHome)();
147
+ node_fs_1.default.writeFileSync(connectionPath(), `${JSON.stringify(connection, null, 2)}\n`, { mode: 0o600 });
148
+ try {
149
+ node_fs_1.default.chmodSync(connectionPath(), 0o600);
150
+ }
151
+ catch {
152
+ /* a filesystem that cannot chmod is not a reason to fail */
153
+ }
154
+ }
155
+ function clearConnection() {
156
+ try {
157
+ node_fs_1.default.rmSync(connectionPath(), { force: true });
158
+ return true;
159
+ }
160
+ catch {
161
+ return false;
162
+ }
163
+ }
164
+ /** Record the outcome of a sync without disturbing anything else. */
165
+ function noteSync(ok, error) {
166
+ const connection = readConnection();
167
+ if (!connection)
168
+ return;
169
+ if (ok) {
170
+ connection.lastSyncedAt = new Date().toISOString();
171
+ delete connection.lastError;
172
+ }
173
+ else {
174
+ connection.lastError = error || 'sync failed';
175
+ }
176
+ // `true`: this is bookkeeping on a connection that already exists and was
177
+ // already allowed. The fence is about how an address gets INSTALLED, and
178
+ // refusing a sync timestamp on a machine legitimately pointed at a local
179
+ // server would throw out of a background timer nobody is catching.
180
+ writeConnection(connection, true);
181
+ }
182
+ /**
183
+ * Why the last enrollment attempt failed, in the tenant's own words.
184
+ *
185
+ * A module-level value rather than a thrown error, because failing to enrol is
186
+ * NOT a failure to sign in: the person is signed in either way, and the
187
+ * difference is only whether this machine can ask for work yet.
188
+ */