@kinlab/kin 0.3.6 → 0.5.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.
package/README.md CHANGED
@@ -16,6 +16,11 @@ npx -y @kinlab/kin --version
16
16
  npx -y @kinlab/kin setup --intent agent --no-interactive
17
17
  ```
18
18
 
19
+ Native Windows x86_64 can install and run repository-free CLI diagnostics, but repository admission is currently unavailable: kin init fails closed, so graph, lexical, daemon, repository setup, MCP, and review workflows are unsupported. Use WSL2 for usable Kin repositories.
20
+ No native Windows ARM64 archive is published. An x64 Node process under Windows
21
+ emulation can provision the x86_64 archive for diagnostics; use WSL2 for the
22
+ repository workflow documented below.
23
+
19
24
  ## What it does
20
25
 
21
26
  `@kinlab/kin` ships two thin launchers, not a JavaScript reimplementation:
@@ -31,7 +36,7 @@ The launcher and the shell installer (`scripts/install.sh`) share the same insta
31
36
  contract (`$KIN_HOME`, default `~/.kin`): either lane satisfies the other, and neither
32
37
  silently downgrades an install the other made.
33
38
 
34
- Run `kin setup --intent agent` after provisioning. Setup writes the Kin MCP server into
39
+ On macOS, Linux, or WSL2, run `kin setup --intent agent` after provisioning. Setup writes the Kin MCP server into
35
40
  detected AI clients with the `agent-default` tool profile, adds the managed bin directory
36
41
  to your shell profile, installs the shell/session hook, and records the install ledger used
37
42
  by `kin setup status`, `kin doctor --fix`, and `kin setup uninstall`.
@@ -75,5 +80,11 @@ Any MCP client can also use the included server manually:
75
80
  }
76
81
  ```
77
82
 
83
+ `kin setup status` and `kin doctor` recognize this exact canonical wrapper topology instead
84
+ of flagging it for repair. Do not shorten `command` to a bare `kin`: agent clients do not
85
+ reliably inherit your shell `PATH`. Codex and Antigravity bindings additionally require
86
+ `"--repo", "/absolute/path/to/repository"` at the end of the argument vector; an Antigravity
87
+ workspace entry also uses that path as `cwd`.
88
+
78
89
  `@kinlab/kin-mcp` remains published for existing configurations; new setups should use
79
90
  this package.
package/lib/provision.mjs CHANGED
@@ -29,6 +29,11 @@ import {
29
29
  writeLauncherStamp,
30
30
  } from './resolve.mjs';
31
31
 
32
+ /** Parse the boolean vocabulary promised by Kin's generated env contract. */
33
+ export function isTruthyEnv(value) {
34
+ return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
35
+ }
36
+
32
37
  /**
33
38
  * Release-artifact file name for a host. Follows the release workflow's
34
39
  * naming (kin-{macos|linux|windows}-{x86_64|aarch64}); Windows ships as .zip.
@@ -79,14 +84,129 @@ export function sha256Hex(buf) {
79
84
  return crypto.createHash('sha256').update(buf).digest('hex');
80
85
  }
81
86
 
87
+ /** Compact, stable byte count for first-install progress. */
88
+ export function formatByteCount(bytes) {
89
+ const value = Math.max(0, Number(bytes) || 0);
90
+ const units = ['B', 'KiB', 'MiB', 'GiB'];
91
+ let amount = value;
92
+ let unit = 0;
93
+ while (amount >= 1024 && unit < units.length - 1) {
94
+ amount /= 1024;
95
+ unit += 1;
96
+ }
97
+ const digits = unit === 0 ? 0 : 1;
98
+ return `${amount.toFixed(digits)} ${units[unit]}`;
99
+ }
100
+
101
+ /** Pure rendering helper so progress wording remains testable without a TTY. */
102
+ export function formatDownloadProgress(file, received, total = null) {
103
+ const transferred = formatByteCount(received);
104
+ if (Number.isFinite(total) && total > 0) {
105
+ const percent = Math.min(100, Math.floor((received * 100) / total));
106
+ return `kin: downloading ${file}: ${percent}% (${transferred} / ${formatByteCount(total)})`;
107
+ }
108
+ return `kin: downloading ${file}: ${transferred} received`;
109
+ }
110
+
111
+ export function createDownloadProgress(file, stream = process.stderr) {
112
+ let lastAt = 0;
113
+ let lastBytes = 0;
114
+ let lastPercent = -1;
115
+ let active = false;
116
+ return ({ received, total, done = false, failed = false }) => {
117
+ if (failed) {
118
+ if (active) stream.write('\r\x1b[2K\n');
119
+ active = false;
120
+ return;
121
+ }
122
+ const now = Date.now();
123
+ const percent = Number.isFinite(total) && total > 0 ? Math.floor((received * 100) / total) : null;
124
+ if (!done) {
125
+ if (percent !== null && percent === lastPercent && now - lastAt < 250) return;
126
+ if (percent === null && received - lastBytes < 1024 * 1024 && now - lastAt < 250) return;
127
+ }
128
+ stream.write(`\r\x1b[2K${formatDownloadProgress(file, received, total)}${done ? '\n' : ''}`);
129
+ active = !done;
130
+ lastAt = now;
131
+ lastBytes = received;
132
+ lastPercent = percent;
133
+ };
134
+ }
135
+
82
136
  async function fetchBuffer(url, fetchImpl) {
83
137
  const res = await fetchImpl(url);
84
138
  if (!res.ok) {
85
139
  throw new Error(`download failed (${res.status}) for ${url}`);
86
140
  }
141
+
87
142
  return Buffer.from(await res.arrayBuffer());
88
143
  }
89
144
 
145
+ /**
146
+ * Download one response directly into a staging file while hashing it. The
147
+ * streaming path consumes and writes one chunk before requesting the next, so
148
+ * archive-sized data is never accumulated in JavaScript memory. Lightweight
149
+ * injected fetches that expose only arrayBuffer() retain their existing one-
150
+ * buffer contract.
151
+ */
152
+ export async function downloadToFile(url, destination, fetchImpl, onProgress = null) {
153
+ let received = 0;
154
+ let total = null;
155
+ try {
156
+ const res = await fetchImpl(url);
157
+ if (!res.ok) {
158
+ throw new Error(`download failed (${res.status}) for ${url}`);
159
+ }
160
+
161
+ const contentLength = Number(res.headers?.get?.('content-length'));
162
+ total = Number.isFinite(contentLength) && contentLength > 0 ? contentLength : null;
163
+ const hash = crypto.createHash('sha256');
164
+
165
+ if (res.body && typeof res.body[Symbol.asyncIterator] === 'function') {
166
+ const fd = fs.openSync(destination, 'w', 0o600);
167
+ try {
168
+ for await (const chunk of res.body) {
169
+ const bytes = ArrayBuffer.isView(chunk)
170
+ ? new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)
171
+ : Buffer.from(chunk);
172
+ let offset = 0;
173
+ while (offset < bytes.byteLength) {
174
+ const written = fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
175
+ if (written <= 0) throw new Error(`archive staging made no progress for ${url}`);
176
+ offset += written;
177
+ }
178
+ hash.update(bytes);
179
+ received += bytes.byteLength;
180
+ onProgress?.({ received, total, done: false });
181
+ }
182
+ } finally {
183
+ fs.closeSync(fd);
184
+ }
185
+ } else {
186
+ // Preserve offline/injected fetch compatibility without adding a second
187
+ // archive-sized allocation: Buffer.from(ArrayBuffer) is a shared view.
188
+ const bytes = Buffer.from(await res.arrayBuffer());
189
+ fs.writeFileSync(destination, bytes, { mode: 0o600 });
190
+ hash.update(bytes);
191
+ received = bytes.length;
192
+ }
193
+
194
+ if (total !== null && received !== total) {
195
+ throw new Error(`download truncated for ${url}: expected ${total} bytes, received ${received}`);
196
+ }
197
+
198
+ onProgress?.({ received, total: total ?? received, done: true });
199
+ return { bytes: received, sha256: hash.digest('hex') };
200
+ } catch (error) {
201
+ try {
202
+ onProgress?.({ received, total, done: true, failed: true });
203
+ } catch {
204
+ // Progress cleanup is best-effort and must not hide the download error.
205
+ }
206
+ throw error;
207
+ }
208
+ }
209
+
90
210
  /**
91
211
  * Locate the extraction root: release archives contain a kin-* subdirectory;
92
212
  * tolerate a flat archive the way install.sh does.
@@ -106,6 +226,185 @@ function installFile(src, dest, mode) {
106
226
  fs.chmodSync(dest, mode);
107
227
  }
108
228
 
229
+ /**
230
+ * Copy a directory tree, refusing anything that is not a directory or a regular
231
+ * file. Written out rather than delegating to fs.cpSync so a symlink inside an
232
+ * extracted archive stops the copy instead of being reproduced under $KIN_HOME,
233
+ * and so the executable bit is carried across deliberately.
234
+ */
235
+ function copyTree(src, dest) {
236
+ const stat = fs.lstatSync(src);
237
+ if (stat.isSymbolicLink()) {
238
+ throw new Error(`refusing to copy symlink from the release archive: ${src}`);
239
+ }
240
+ if (stat.isDirectory()) {
241
+ fs.mkdirSync(dest, { recursive: true });
242
+ for (const entry of fs.readdirSync(src)) {
243
+ copyTree(path.join(src, entry), path.join(dest, entry));
244
+ }
245
+ return;
246
+ }
247
+ if (!stat.isFile()) {
248
+ throw new Error(`refusing to copy a non-regular archive entry: ${src}`);
249
+ }
250
+ fs.copyFileSync(src, dest);
251
+ fs.chmodSync(dest, stat.mode & 0o111 ? 0o755 : 0o644);
252
+ }
253
+
254
+ /** Read-only counterpart to copyTree, used before any live install mutation. */
255
+ function validateTree(src) {
256
+ const stat = fs.lstatSync(src);
257
+ if (stat.isSymbolicLink()) {
258
+ throw new Error(`refusing symlink in the release archive: ${src}`);
259
+ }
260
+ if (stat.isDirectory()) {
261
+ for (const entry of fs.readdirSync(src)) {
262
+ validateTree(path.join(src, entry));
263
+ }
264
+ return;
265
+ }
266
+ if (!stat.isFile()) {
267
+ throw new Error(`refusing a non-regular archive entry: ${src}`);
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Install the macOS notification bundle from an extracted release archive.
273
+ *
274
+ * macOS reads a notification's sender name, icon, and grouping from the posting
275
+ * process's bundle; a CLI has none, so without this every Kin notification is
276
+ * credited to Script Editor. That is a silent downgrade rather than a visible
277
+ * failure, which is why its absence is reported here rather than passed over.
278
+ *
279
+ * Replaced whole rather than merged, for the same reason install.sh does: a
280
+ * stale executable left inside a newer bundle breaks the signature seal macOS
281
+ * checks over the bundle as a unit.
282
+ *
283
+ * Returns the validated bundle source, or null on a platform with no bundle.
284
+ */
285
+ function preflightNotifierBundle(root, platform) {
286
+ if (platform !== 'darwin') return null;
287
+ const src = path.join(root, 'KinNotifier.app');
288
+ if (!fs.existsSync(src)) {
289
+ throw new Error(
290
+ 'this macOS release archive carries no KinNotifier.app; refusing to replace binaries or ' +
291
+ 'stamp the release with a missing notification identity',
292
+ );
293
+ }
294
+ const rootStat = fs.lstatSync(src);
295
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
296
+ throw new Error('KinNotifier.app in the macOS release archive is not a real directory');
297
+ }
298
+ validateTree(src);
299
+ for (const required of ['Contents/MacOS/KinNotifier', 'Contents/Info.plist']) {
300
+ const member = path.join(src, required);
301
+ if (!fs.existsSync(member)) {
302
+ throw new Error(
303
+ `this macOS release archive's KinNotifier.app is missing ${required}; refusing to ` +
304
+ 'replace binaries or stamp the release',
305
+ );
306
+ }
307
+ const stat = fs.lstatSync(member);
308
+ if (stat.isSymbolicLink() || !stat.isFile()) {
309
+ throw new Error(`KinNotifier.app/${required} is not a regular file`);
310
+ }
311
+ if (stat.size === 0) {
312
+ throw new Error(`KinNotifier.app/${required} is empty`);
313
+ }
314
+ if (required.endsWith('/KinNotifier') && (stat.mode & 0o111) === 0) {
315
+ throw new Error(`KinNotifier.app/${required} is not executable`);
316
+ }
317
+ }
318
+ return src;
319
+ }
320
+
321
+ function installNotifierBundleSource(src, env) {
322
+ const libDir = path.join(kinHome(env), 'lib');
323
+ fs.mkdirSync(libDir, { recursive: true });
324
+ const dest = path.join(libDir, 'KinNotifier.app');
325
+ // Build the incoming tree beside the live one and swap, rather than removing
326
+ // the live one and copying into the gap it leaves. copyTree refuses a symlink
327
+ // or a non-regular entry part-way down, and a bundle interrupted there has a
328
+ // launchable executable with no `Info.plist` behind it, which every freshness
329
+ // check reads as an installed bundle. The updater stages and renames for the
330
+ // same reason.
331
+ const staged = path.join(libDir, '.KinNotifier.app.incoming');
332
+ try {
333
+ fs.rmSync(staged, { recursive: true, force: true });
334
+ copyTree(src, staged);
335
+ fs.chmodSync(path.join(staged, 'Contents', 'MacOS', 'KinNotifier'), 0o755);
336
+ fs.rmSync(dest, { recursive: true, force: true });
337
+ fs.renameSync(staged, dest);
338
+ } finally {
339
+ fs.rmSync(staged, { recursive: true, force: true });
340
+ }
341
+
342
+ // Registering with LaunchServices is what lets the notification daemon
343
+ // validate the bundle; an unregistered app is refused outright. Authorization
344
+ // itself is NOT requested here: an unanswered prompt is recorded as a
345
+ // permanent denial, so it must be raised interactively by `kin setup`.
346
+ const lsregister =
347
+ '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister';
348
+ if (fs.existsSync(lsregister)) {
349
+ spawnSync(lsregister, ['-f', dest], { stdio: 'ignore' });
350
+ }
351
+ return true;
352
+ }
353
+
354
+ /** Validate and install the macOS bundle as one tree. */
355
+ export function installNotifierBundle(root, env, platform, log) {
356
+ void log;
357
+ const src = preflightNotifierBundle(root, platform);
358
+ return src ? installNotifierBundleSource(src, env) : false;
359
+ }
360
+
361
+ function mergeEnvironment(base, overrides) {
362
+ const merged = { ...base };
363
+ for (const [name, value] of Object.entries(overrides || {})) {
364
+ for (const inherited of Object.keys(merged)) {
365
+ if (inherited.toLowerCase() === name.toLowerCase()) {
366
+ delete merged[inherited];
367
+ }
368
+ }
369
+ merged[name] = value;
370
+ }
371
+ return merged;
372
+ }
373
+
374
+ function environmentValue(env, name) {
375
+ const key = Object.keys(env).find(
376
+ (candidate) => candidate.toLowerCase() === name.toLowerCase(),
377
+ );
378
+ return key === undefined ? undefined : env[key];
379
+ }
380
+
381
+ function windowsSystemTarPath(env) {
382
+ const systemRoot = environmentValue(env, 'SystemRoot');
383
+ if (!systemRoot) {
384
+ throw new Error('native Windows ZIP extraction requires SystemRoot');
385
+ }
386
+ return path.win32.join(systemRoot, 'System32', 'tar.exe');
387
+ }
388
+
389
+ function archiveExtraction(platform, env, file) {
390
+ if (platform === 'win32') {
391
+ if (process.platform === 'win32') {
392
+ return {
393
+ executable: windowsSystemTarPath(env),
394
+ args: ['-xf', file, '-C', '.'],
395
+ };
396
+ }
397
+ // Cross-target tests on Unix exercise genuine ZIP bytes with the host's
398
+ // deterministic system unzip. Production never installs Windows assets
399
+ // on a Unix host.
400
+ return {
401
+ executable: '/usr/bin/unzip',
402
+ args: ['-q', file, '-d', '.'],
403
+ };
404
+ }
405
+ return { executable: 'tar', args: ['-xf', file, '-C', '.'] };
406
+ }
407
+
109
408
  /**
110
409
  * Download, verify, and install the pinned Kin release. Returns the installed
111
410
  * managed `kin` path. Mirrors scripts/install.sh: kin + kin-daemon are
@@ -118,34 +417,57 @@ export async function provision(version, opts = {}) {
118
417
  arch = process.arch,
119
418
  fetchImpl = fetch,
120
419
  log = (line) => process.stderr.write(`${line}\n`),
420
+ onProgress,
121
421
  } = opts;
122
422
 
123
423
  const file = artifactName(platform, arch);
124
424
  const url = releaseDownloadUrl(version, file);
125
425
  log(`kin: provisioning managed kin ${version} (${file})...`);
126
426
 
127
- const [archive, shaText] = await Promise.all([
128
- fetchBuffer(url, fetchImpl),
129
- fetchBuffer(`${url}.sha256`, fetchImpl),
130
- ]);
131
- const expected = parseSha256File(shaText.toString('utf8'));
132
- const actual = sha256Hex(archive);
133
- if (actual !== expected) {
134
- throw new Error(
135
- `SHA-256 mismatch for ${file}: expected ${expected}, got ${actual}. ` +
136
- 'The download may be corrupted or tampered with; refusing to install.',
137
- );
138
- }
427
+ // Interactive first runs show live bytes/percent; redirected/non-TTY npm
428
+ // invocations stay line-oriented. An injected callback keeps streaming fully
429
+ // testable without coupling fake fetch implementations to terminal behavior.
430
+ const archiveProgress =
431
+ onProgress === undefined && fetchImpl === globalThis.fetch && process.stderr.isTTY
432
+ ? createDownloadProgress(file)
433
+ : onProgress;
139
434
 
140
435
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kinlab-kin-'));
141
436
  try {
142
437
  const archivePath = path.join(tmp, file);
143
- fs.writeFileSync(archivePath, archive);
144
- // bsdtar reads both .tar.gz and .zip with -xf, on macOS, Linux, and
145
- // Windows 10+ (tar.exe ships in System32).
146
- const tar = spawnSync('tar', ['-xf', archivePath, '-C', tmp], { encoding: 'utf8' });
147
- if (tar.status !== 0) {
148
- throw new Error(`archive extraction failed: ${tar.stderr || tar.error?.message || 'tar exited non-zero'}`);
438
+ // Wait for both concurrent fetches to settle before removing the staging
439
+ // directory. Promise.all would race cleanup against an archive stream when
440
+ // the small checksum request fails first.
441
+ const [archiveResult, checksumResult] = await Promise.allSettled([
442
+ downloadToFile(url, archivePath, fetchImpl, archiveProgress),
443
+ fetchBuffer(`${url}.sha256`, fetchImpl),
444
+ ]);
445
+ if (archiveResult.status === 'rejected') throw archiveResult.reason;
446
+ if (checksumResult.status === 'rejected') throw checksumResult.reason;
447
+
448
+ const expected = parseSha256File(checksumResult.value.toString('utf8'));
449
+ const actual = archiveResult.value.sha256;
450
+ if (actual !== expected) {
451
+ throw new Error(
452
+ `SHA-256 mismatch for ${file}: expected ${expected}, got ${actual}. ` +
453
+ 'The download may be corrupted or tampered with; refusing to install.',
454
+ );
455
+ }
456
+
457
+ const toolEnv = mergeEnvironment(process.env, env);
458
+ const extraction = archiveExtraction(platform, toolEnv, file);
459
+ // Windows authority is the absolute System32 bsdtar, never a Git/MSYS or
460
+ // user-provided `tar` found through PATH. Relative operands also avoid the
461
+ // GNU remote-host interpretation of `C:\\...` paths.
462
+ const extracted = spawnSync(extraction.executable, extraction.args, {
463
+ cwd: tmp,
464
+ encoding: 'utf8',
465
+ env: toolEnv,
466
+ });
467
+ if (extracted.status !== 0) {
468
+ throw new Error(
469
+ `archive extraction failed: ${extracted.stderr || extracted.error?.message || 'extractor exited non-zero'}`,
470
+ );
149
471
  }
150
472
 
151
473
  const root = extractionRoot(tmp);
@@ -158,6 +480,12 @@ export async function provision(version, opts = {}) {
158
480
  );
159
481
  }
160
482
 
483
+ // A macOS bundle is part of the release contract, not an optional extra.
484
+ // Validate it before creating or replacing anything under KIN_HOME so a
485
+ // malformed upgrade cannot stamp new binaries while retaining a stale
486
+ // live notifier from the previous release.
487
+ const notifierSrc = preflightNotifierBundle(root, platform);
488
+
161
489
  const binDir = path.join(kinHome(env), 'bin');
162
490
  const libDir = path.join(kinHome(env), 'lib');
163
491
  fs.mkdirSync(binDir, { recursive: true });
@@ -177,6 +505,10 @@ export async function provision(version, opts = {}) {
177
505
  }
178
506
  }
179
507
 
508
+ if (notifierSrc && installNotifierBundleSource(notifierSrc, env)) {
509
+ log('kin: notification identity installed (KinNotifier.app)');
510
+ }
511
+
180
512
  writeLauncherStamp(version, env);
181
513
  log(`kin: managed kin ${version} installed at ${binDir}`);
182
514
  return path.join(binDir, binaryName('kin', platform));
@@ -234,13 +566,13 @@ export async function ensureProvisioned(opts = {}) {
234
566
  return resolveManagedBinary('kin', env, platform);
235
567
  }
236
568
  const existing = resolveManagedBinary('kin', env, platform);
237
- if (env.KIN_NO_PROVISION === '1') {
569
+ if (isTruthyEnv(env.KIN_NO_PROVISION)) {
238
570
  return existing;
239
571
  }
240
572
 
241
573
  const doProvision = () => provision(target, { env, platform, arch, fetchImpl, log });
242
574
 
243
- if (env.KIN_LAUNCHER_ADOPT === '1') {
575
+ if (isTruthyEnv(env.KIN_LAUNCHER_ADOPT)) {
244
576
  return doProvision();
245
577
  }
246
578
  if (!existing) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinlab/kin",
3
- "version": "0.3.6",
3
+ "version": "0.5.1",
4
4
  "description": "Canonical installer and launcher for Kin, the system of record for AI-written software. Provisions and runs the managed kin + kin-daemon release. MCP is one included mode (kin mcp start).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",