@hasna/todos 0.15.52 → 0.16.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 (183) hide show
  1. package/CHANGELOG.md +1884 -0
  2. package/README.md +314 -37
  3. package/dist/ai-tools.d.ts.map +1 -1
  4. package/dist/ai.d.ts.map +1 -1
  5. package/dist/cli/cloud-router.d.ts +99 -51
  6. package/dist/cli/cloud-router.d.ts.map +1 -1
  7. package/dist/cli/commands/agent-commands.d.ts.map +1 -1
  8. package/dist/cli/commands/config-serve-commands.d.ts.map +1 -1
  9. package/dist/cli/commands/delegate.d.ts.map +1 -1
  10. package/dist/cli/commands/environment-snapshots.d.ts.map +1 -1
  11. package/dist/cli/commands/machines-api.d.ts +4 -0
  12. package/dist/cli/commands/machines-api.d.ts.map +1 -0
  13. package/dist/cli/commands/machines.d.ts.map +1 -1
  14. package/dist/cli/commands/mcp-hooks-commands.d.ts.map +1 -1
  15. package/dist/cli/commands/plan-commands.d.ts +3 -0
  16. package/dist/cli/commands/plan-commands.d.ts.map +1 -0
  17. package/dist/cli/commands/plan-template-commands.d.ts.map +1 -1
  18. package/dist/cli/commands/pr-group-commands.d.ts.map +1 -1
  19. package/dist/cli/commands/project-commands.d.ts.map +1 -1
  20. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  21. package/dist/cli/commands/storage-commands.d.ts.map +1 -1
  22. package/dist/cli/commands/task-list-commands.d.ts +3 -0
  23. package/dist/cli/commands/task-list-commands.d.ts.map +1 -0
  24. package/dist/cli/commands/template-commands.d.ts +3 -0
  25. package/dist/cli/commands/template-commands.d.ts.map +1 -0
  26. package/dist/cli/helpers.d.ts.map +1 -1
  27. package/dist/cli/index.js +15128 -11093
  28. package/dist/cli/machine-api.d.ts +7 -0
  29. package/dist/cli/machine-api.d.ts.map +1 -0
  30. package/dist/cli/plan-read-api.d.ts +6 -0
  31. package/dist/cli/plan-read-api.d.ts.map +1 -0
  32. package/dist/cli/project-api.d.ts +119 -0
  33. package/dist/cli/project-api.d.ts.map +1 -0
  34. package/dist/cli/stage-a.d.ts +34 -4
  35. package/dist/cli/stage-a.d.ts.map +1 -1
  36. package/dist/cli/task-query-api.d.ts +21 -0
  37. package/dist/cli/task-query-api.d.ts.map +1 -0
  38. package/dist/cli/template-api.d.ts +7 -0
  39. package/dist/cli/template-api.d.ts.map +1 -0
  40. package/dist/contracts.js +835 -265
  41. package/dist/db/builtin-templates.d.ts +2 -33
  42. package/dist/db/builtin-templates.d.ts.map +1 -1
  43. package/dist/db/database.d.ts.map +1 -1
  44. package/dist/db/handoffs.d.ts.map +1 -1
  45. package/dist/db/machines.d.ts.map +1 -1
  46. package/dist/db/migrations.d.ts.map +1 -1
  47. package/dist/db/plans.d.ts.map +1 -1
  48. package/dist/db/retrospectives.d.ts.map +1 -1
  49. package/dist/db/schema.d.ts.map +1 -1
  50. package/dist/db/task-lists.d.ts.map +1 -1
  51. package/dist/db/task-runs.d.ts +2 -0
  52. package/dist/db/task-runs.d.ts.map +1 -1
  53. package/dist/index.d.ts +2 -2
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +3862 -1715
  56. package/dist/lib/assignee-validation.d.ts.map +1 -1
  57. package/dist/lib/builtin-template-library.d.ts +32 -0
  58. package/dist/lib/builtin-template-library.d.ts.map +1 -0
  59. package/dist/lib/cli-reference.d.ts.map +1 -1
  60. package/dist/lib/config.d.ts +10 -13
  61. package/dist/lib/config.d.ts.map +1 -1
  62. package/dist/lib/db-backup.d.ts +1 -1
  63. package/dist/lib/db-backup.d.ts.map +1 -1
  64. package/dist/lib/env.d.ts +18 -0
  65. package/dist/lib/env.d.ts.map +1 -0
  66. package/dist/lib/environment-snapshots.d.ts.map +1 -1
  67. package/dist/lib/gatherer.d.ts.map +1 -1
  68. package/dist/lib/headless-boundaries.d.ts +4 -3
  69. package/dist/lib/headless-boundaries.d.ts.map +1 -1
  70. package/dist/lib/local-encryption.d.ts +12 -0
  71. package/dist/lib/local-encryption.d.ts.map +1 -1
  72. package/dist/lib/local-opt-in.d.ts +90 -0
  73. package/dist/lib/local-opt-in.d.ts.map +1 -0
  74. package/dist/lib/paths.d.ts +9 -0
  75. package/dist/lib/paths.d.ts.map +1 -1
  76. package/dist/lib/plan-artifacts.d.ts +1 -0
  77. package/dist/lib/plan-artifacts.d.ts.map +1 -1
  78. package/dist/lib/plan-client-boundary.d.ts +2 -0
  79. package/dist/lib/plan-client-boundary.d.ts.map +1 -0
  80. package/dist/lib/plan-schedule.d.ts +6 -0
  81. package/dist/lib/plan-schedule.d.ts.map +1 -0
  82. package/dist/lib/project-panel.d.ts +6 -0
  83. package/dist/lib/project-panel.d.ts.map +1 -1
  84. package/dist/lib/project-receipt.d.ts +3 -0
  85. package/dist/lib/project-receipt.d.ts.map +1 -0
  86. package/dist/lib/sandbox-profiles.d.ts.map +1 -1
  87. package/dist/lib/shared-plan-artifacts.d.ts +14 -0
  88. package/dist/lib/shared-plan-artifacts.d.ts.map +1 -0
  89. package/dist/lib/sync.d.ts.map +1 -1
  90. package/dist/lib/task-list-client-boundary.d.ts +3 -0
  91. package/dist/lib/task-list-client-boundary.d.ts.map +1 -0
  92. package/dist/lib/task-routing.d.ts.map +1 -1
  93. package/dist/lib/template-client-boundary.d.ts +2 -0
  94. package/dist/lib/template-client-boundary.d.ts.map +1 -0
  95. package/dist/mcp/index.d.ts +1 -0
  96. package/dist/mcp/index.d.ts.map +1 -1
  97. package/dist/mcp/index.js +45271 -40452
  98. package/dist/mcp/plan-api.d.ts +6 -0
  99. package/dist/mcp/plan-api.d.ts.map +1 -0
  100. package/dist/mcp/remote-authority.d.ts +49 -0
  101. package/dist/mcp/remote-authority.d.ts.map +1 -0
  102. package/dist/mcp/task-coordination-api.d.ts +15 -0
  103. package/dist/mcp/task-coordination-api.d.ts.map +1 -0
  104. package/dist/mcp/task-list-api.d.ts +8 -0
  105. package/dist/mcp/task-list-api.d.ts.map +1 -0
  106. package/dist/mcp/token-utils.d.ts.map +1 -1
  107. package/dist/mcp/tools/environment-snapshots.d.ts.map +1 -1
  108. package/dist/mcp/tools/machines.d.ts.map +1 -1
  109. package/dist/mcp/tools/task-project-tools.d.ts.map +1 -1
  110. package/dist/mcp/tools/task-resources.d.ts.map +1 -1
  111. package/dist/mcp.d.ts +2 -2
  112. package/dist/mcp.d.ts.map +1 -1
  113. package/dist/mcp.js +44 -18
  114. package/dist/project-registration.js +2829 -556
  115. package/dist/registry.js +836 -266
  116. package/dist/release-provenance.json +6 -6
  117. package/dist/sdk/client.d.ts +51 -4
  118. package/dist/sdk/client.d.ts.map +1 -1
  119. package/dist/sdk/index.d.ts +2 -0
  120. package/dist/sdk/index.d.ts.map +1 -1
  121. package/dist/sdk/index.js +1209 -1080
  122. package/dist/sdk/resolve.d.ts +91 -0
  123. package/dist/sdk/resolve.d.ts.map +1 -0
  124. package/dist/sdk/v1.generated.d.ts +219 -3
  125. package/dist/sdk/v1.generated.d.ts.map +1 -1
  126. package/dist/server/auth-posture.d.ts +66 -7
  127. package/dist/server/auth-posture.d.ts.map +1 -1
  128. package/dist/server/client-ip.d.ts +39 -0
  129. package/dist/server/client-ip.d.ts.map +1 -0
  130. package/dist/server/cloud.d.ts +2 -1
  131. package/dist/server/cloud.d.ts.map +1 -1
  132. package/dist/server/index.d.ts +1 -1
  133. package/dist/server/index.js +20556 -15541
  134. package/dist/server/openapi.d.ts +1341 -83
  135. package/dist/server/openapi.d.ts.map +1 -1
  136. package/dist/server/port.d.ts +2 -2
  137. package/dist/server/port.d.ts.map +1 -1
  138. package/dist/server/routes.d.ts +3 -6
  139. package/dist/server/routes.d.ts.map +1 -1
  140. package/dist/server/serve.d.ts +2 -6
  141. package/dist/server/serve.d.ts.map +1 -1
  142. package/dist/server/v1.d.ts +3 -1
  143. package/dist/server/v1.d.ts.map +1 -1
  144. package/dist/storage/atomic-project-migration.d.ts +53 -0
  145. package/dist/storage/atomic-project-migration.d.ts.map +1 -0
  146. package/dist/storage/index.d.ts +3 -1
  147. package/dist/storage/index.d.ts.map +1 -1
  148. package/dist/storage/interfaces.d.ts +61 -0
  149. package/dist/storage/interfaces.d.ts.map +1 -1
  150. package/dist/storage/machine-registry.d.ts +36 -0
  151. package/dist/storage/machine-registry.d.ts.map +1 -0
  152. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  153. package/dist/storage/postgres-sync.d.ts +2 -2
  154. package/dist/storage/postgres-sync.d.ts.map +1 -1
  155. package/dist/storage/run-artifact-creation-upload.d.ts +50 -0
  156. package/dist/storage/run-artifact-creation-upload.d.ts.map +1 -0
  157. package/dist/storage/s3-artifact-sync.d.ts +18 -0
  158. package/dist/storage/s3-artifact-sync.d.ts.map +1 -1
  159. package/dist/storage/sqlite-snapshot.d.ts +2 -0
  160. package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
  161. package/dist/storage.d.ts +2 -2
  162. package/dist/storage.d.ts.map +1 -1
  163. package/dist/storage.js +2695 -322
  164. package/dist/task-manifest/postgres.d.ts +1 -1
  165. package/dist/task-manifest/sqlite.d.ts +1 -1
  166. package/dist/task-manifest.js +244 -73
  167. package/dist/task-subtree-transfer.js +198 -4
  168. package/dist/testing.d.ts +90 -29
  169. package/dist/testing.d.ts.map +1 -1
  170. package/dist/testing.js +34 -10
  171. package/dist/types/index.d.ts +40 -4
  172. package/dist/types/index.d.ts.map +1 -1
  173. package/docs/PLAN_API.md +93 -0
  174. package/docs/TASK_LIST_API.md +58 -0
  175. package/docs/TASK_QUERY_API.md +14 -0
  176. package/docs/TEMPLATE_API.md +18 -0
  177. package/docs/native-storage.md +405 -0
  178. package/package.json +18 -15
  179. package/postinstall.js +52 -1
  180. package/dashboard/dist/assets/index-BNQ4gJua.js +0 -342
  181. package/dashboard/dist/assets/index-DjzvHUWt.css +0 -1
  182. package/dashboard/dist/index.html +0 -13
  183. package/dashboard/dist/logo.jpg +0 -0
@@ -46,7 +46,7 @@ function isBlockingDependencyStatus(status) {
46
46
  function isTerminalStatus(status) {
47
47
  return status === "completed" || status === "failed" || status === "cancelled";
48
48
  }
49
- var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
49
+ var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError, InputValidationError;
50
50
  var init_types = __esm(() => {
51
51
  TASK_STATUSES = [
52
52
  "pending",
@@ -61,7 +61,7 @@ var init_types = __esm(() => {
61
61
  "high",
62
62
  "critical"
63
63
  ];
64
- PLAN_STATUSES = ["active", "completed", "archived"];
64
+ PLAN_STATUSES = ["active", "completed", "archived", "planning", "cancelled"];
65
65
  VersionConflictError = class VersionConflictError extends Error {
66
66
  taskId;
67
67
  expectedVersion;
@@ -250,6 +250,16 @@ var init_types = __esm(() => {
250
250
  this.name = "DispatchNotFoundError";
251
251
  }
252
252
  };
253
+ InputValidationError = class InputValidationError extends Error {
254
+ static code = "INVALID_INPUT";
255
+ code = InputValidationError.code;
256
+ suggestion;
257
+ constructor(message, suggestion) {
258
+ super(message);
259
+ this.name = "InputValidationError";
260
+ this.suggestion = suggestion;
261
+ }
262
+ };
253
263
  });
254
264
 
255
265
  // src/lib/slugs.ts
@@ -378,6 +388,171 @@ function deriveTodosTaskSubtreeTransferIdempotencyKey(input) {
378
388
  }).slice(0, 48)}`;
379
389
  }
380
390
 
391
+ // src/storage/machine-registry.ts
392
+ import { randomUUID } from "crypto";
393
+ import { isDeepStrictEqual } from "util";
394
+ class MachineRegistryError extends Error {
395
+ status;
396
+ constructor(message, status = 409) {
397
+ super(message);
398
+ this.status = status;
399
+ }
400
+ }
401
+ var fields = new Set(["id", "name", "hostname", "platform", "last_seen_at", "metadata", "created_at", "ssh_address", "is_primary", "archived_at"]);
402
+ function validateMachines(value) {
403
+ if (!Array.isArray(value) || value.length > 1e4)
404
+ throw new MachineRegistryError("machines must be an array of at most 10000 complete records", 400);
405
+ for (const row of value) {
406
+ if (!row || typeof row !== "object" || Array.isArray(row) || Object.keys(row).some((key) => !fields.has(key)))
407
+ throw new MachineRegistryError("Invalid machine record fields", 400);
408
+ for (const key of ["id", "name", "last_seen_at", "created_at"])
409
+ if (typeof row[key] !== "string" || !row[key].trim() || row[key].length > 1024)
410
+ throw new MachineRegistryError(`Invalid machine ${key}`, 400);
411
+ for (const key of ["hostname", "platform", "ssh_address", "archived_at"])
412
+ if (row[key] !== null && typeof row[key] !== "string")
413
+ throw new MachineRegistryError(`Invalid machine ${key}`, 400);
414
+ for (const key of ["last_seen_at", "created_at", "archived_at"])
415
+ if (row[key] !== null && !Number.isFinite(Date.parse(row[key])))
416
+ throw new MachineRegistryError(`Invalid machine ${key}`, 400);
417
+ if (typeof row.is_primary !== "boolean" || !row.metadata || typeof row.metadata !== "object" || Array.isArray(row.metadata))
418
+ throw new MachineRegistryError("Invalid machine flags or metadata", 400);
419
+ if (!isDeepStrictEqual(JSON.parse(JSON.stringify(row.metadata)), row.metadata))
420
+ throw new MachineRegistryError("Machine metadata must preserve JSON values exactly", 400);
421
+ if (JSON.stringify(row.metadata).length > 262144)
422
+ throw new MachineRegistryError("Machine metadata is too large", 400);
423
+ if (row.is_primary && row.archived_at !== null)
424
+ throw new MachineRegistryError("An archived machine cannot be primary", 400);
425
+ }
426
+ return value;
427
+ }
428
+ function planMachineImport(existing, incoming) {
429
+ const rows = validateMachines(incoming);
430
+ const byId = new Map(existing.map((row) => [row.id, row]));
431
+ const byName = new Map(existing.map((row) => [row.name, row.id]));
432
+ const insert = [];
433
+ let skipped = 0;
434
+ for (const row of rows) {
435
+ const prior = byId.get(row.id);
436
+ if (prior && !isDeepStrictEqual(prior, row))
437
+ throw new MachineRegistryError("Machine migration conflicts with an existing identity; reconcile before retrying");
438
+ if (byName.has(row.name) && byName.get(row.name) !== row.id)
439
+ throw new MachineRegistryError("Machine name belongs to a different identity; migration was not applied");
440
+ if (prior)
441
+ skipped++;
442
+ else {
443
+ insert.push(row);
444
+ byId.set(row.id, row);
445
+ byName.set(row.name, row.id);
446
+ }
447
+ }
448
+ if ([...byId.values()].filter((row) => row.is_primary).length > 1)
449
+ throw new MachineRegistryError("Machine migration would create multiple primary machines");
450
+ return { rows: insert, skipped };
451
+ }
452
+ function createPostgresMachineRegistry(client, service, table, ensure) {
453
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table))
454
+ throw new Error("Invalid machine registry table");
455
+ async function list(connection = client) {
456
+ await ensure();
457
+ const result = await connection.query(`SELECT payload FROM ${table} WHERE service=$1 AND object_type='machines' AND deleted_at IS NULL ORDER BY object_id`, [service]);
458
+ return validateMachines(result.rows.map((row) => typeof row.payload === "string" ? JSON.parse(row.payload) : row.payload));
459
+ }
460
+ return { list, async execute(input) {
461
+ if (!input || typeof input !== "object" || Array.isArray(input) || !["register", "heartbeat", "set-primary", "archive", "unarchive", "delete", "import"].includes(input.action))
462
+ throw new MachineRegistryError("Invalid machine operation", 400);
463
+ if (Object.keys(input).some((key) => !["action", "name", "id", "options", "machines"].includes(key)))
464
+ throw new MachineRegistryError("Unknown machine operation fields", 400);
465
+ if (input.options !== undefined && (!input.options || typeof input.options !== "object" || Array.isArray(input.options)))
466
+ throw new MachineRegistryError("Invalid machine options", 400);
467
+ await ensure();
468
+ if (!client.transaction)
469
+ throw new MachineRegistryError("Machine registry requires a transactional server backend", 501);
470
+ return client.transaction(async (tx) => {
471
+ await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${service}:machine-registry`]);
472
+ const current = await list(tx);
473
+ let changed = [];
474
+ let skipped = 0;
475
+ let machine;
476
+ let deleted;
477
+ const stamp = new Date().toISOString();
478
+ if (input.action === "import") {
479
+ const plan = planMachineImport(current, input.machines);
480
+ const ids = plan.rows.map((row) => row.id);
481
+ if (ids.length) {
482
+ const retired = await tx.query(`SELECT object_id FROM ${table} WHERE service=$1 AND object_type='machines' AND object_id IN (SELECT jsonb_array_elements_text($2::text::jsonb)) AND deleted_at IS NOT NULL`, [service, JSON.stringify(ids)]);
483
+ if (retired.rows.length)
484
+ throw new MachineRegistryError("Machine migration contains a retired identity");
485
+ }
486
+ changed = plan.rows;
487
+ skipped = plan.skipped;
488
+ } else {
489
+ if (typeof input.name !== "string" || !input.name.trim() || input.name.length > 1024)
490
+ throw new MachineRegistryError("Machine name or ID is required", 400);
491
+ const matches = current.filter((row) => row.name === input.name || row.id === input.name);
492
+ if (matches.length > 1)
493
+ throw new MachineRegistryError("Machine selector is ambiguous between a name and another identity; use an unambiguous selector");
494
+ const prior = matches[0];
495
+ if (input.action === "register" || input.action === "heartbeat") {
496
+ const opts = input.options ?? {};
497
+ const allowed = new Set(["hostname", "platform", "ssh_address", "primary", "tailscale_name", "tailscale_ip", "lan_address", "workspace_path", "git_root", "arch"]);
498
+ for (const [key, value] of Object.entries(opts)) {
499
+ if (!allowed.has(key) || (key === "primary" ? typeof value !== "boolean" : typeof value !== "string" || value.length > 4096))
500
+ throw new MachineRegistryError("Invalid machine registration options", 400);
501
+ }
502
+ if (input.id !== undefined && (typeof input.id !== "string" || !input.id.trim() || input.id.length > 1024))
503
+ throw new MachineRegistryError("Invalid stable machine ID", 400);
504
+ if (prior && input.id && input.id !== prior.id)
505
+ throw new MachineRegistryError("Machine registration ID does not match existing identity");
506
+ if (!prior && input.id && current.some((row) => row.id === input.id))
507
+ throw new MachineRegistryError("Machine ID already belongs to another name");
508
+ const metadata = { ...prior?.metadata };
509
+ for (const key of ["tailscale_name", "tailscale_ip", "lan_address", "workspace_path", "git_root", "arch"])
510
+ if (opts[key] !== undefined)
511
+ metadata[key] = opts[key];
512
+ if (!prior && input.id) {
513
+ const retired = await tx.query(`SELECT object_id FROM ${table} WHERE service=$1 AND object_type='machines' AND object_id=$2 AND deleted_at IS NOT NULL`, [service, input.id]);
514
+ if (retired.rows.length)
515
+ throw new MachineRegistryError("Machine identity has been retired");
516
+ }
517
+ machine = { id: prior?.id ?? input.id ?? randomUUID(), name: prior?.name ?? input.name, hostname: opts.hostname ?? prior?.hostname ?? null, platform: opts.platform ?? prior?.platform ?? null, ssh_address: opts.ssh_address ?? prior?.ssh_address ?? null, created_at: prior?.created_at ?? stamp, last_seen_at: stamp, archived_at: prior?.archived_at ?? null, is_primary: opts.primary === true || (prior?.is_primary ?? false), metadata };
518
+ if (machine.archived_at && machine.is_primary)
519
+ throw new MachineRegistryError("Cannot set archived machine as primary");
520
+ changed = [machine];
521
+ if (opts.primary === true)
522
+ changed.push(...current.filter((row) => row.is_primary && row.id !== machine.id).map((row) => ({ ...row, is_primary: false })));
523
+ } else {
524
+ if (!prior)
525
+ throw new MachineRegistryError("Machine not found", 404);
526
+ machine = { ...prior };
527
+ if (input.action === "set-primary") {
528
+ if (prior.archived_at)
529
+ throw new MachineRegistryError("Cannot set archived machine as primary");
530
+ machine.is_primary = true;
531
+ changed = current.filter((row) => row.is_primary && row.id !== prior.id).map((row) => ({ ...row, is_primary: false }));
532
+ } else if (input.action === "archive" || input.action === "delete") {
533
+ if (prior.is_primary)
534
+ throw new MachineRegistryError("Cannot archive or delete the primary machine");
535
+ const refs = await tx.query(`SELECT object_id FROM ${table} WHERE service=$1 AND object_type<>'machines' AND deleted_at IS NULL AND payload->>'machine_id'=$2 AND ($3='delete' OR (object_type='tasks' AND payload->>'status' IN ('pending','in_progress'))) LIMIT 1`, [service, prior.id, input.action]);
536
+ if (refs.rows.length)
537
+ throw new MachineRegistryError("Machine still has referenced records or active tasks");
538
+ machine.archived_at = stamp;
539
+ deleted = input.action === "delete" ? true : undefined;
540
+ } else if (input.action === "unarchive")
541
+ machine.archived_at = null;
542
+ else
543
+ throw new MachineRegistryError("Unknown machine action", 400);
544
+ changed.push(machine);
545
+ }
546
+ }
547
+ for (const row of changed) {
548
+ await tx.query(`INSERT INTO ${table} (service,object_type,object_id,payload,updated_at,deleted_at,version) VALUES ($1,'machines',$2,$3::text::jsonb,$4,$5,1) ON CONFLICT (service,object_type,object_id) DO UPDATE SET payload=EXCLUDED.payload,updated_at=EXCLUDED.updated_at,deleted_at=EXCLUDED.deleted_at,version=COALESCE(${table}.version,0)+1`, [service, row.id, JSON.stringify(row), stamp, deleted && row.id === machine?.id ? stamp : null]);
549
+ }
550
+ const machines = await list(tx);
551
+ return { schema_version: 2, machines, ...machine ? { machine } : {}, inserted: changed.filter((row) => !current.some((old) => old.id === row.id)).length, skipped, ...deleted ? { deleted } : {} };
552
+ });
553
+ } };
554
+ }
555
+
381
556
  // src/storage/postgres-sync.ts
382
557
  init_types();
383
558
 
@@ -600,6 +775,10 @@ class PostgresTodosSyncStore {
600
775
  }
601
776
  }
602
777
  async pushSnapshot(snapshot, context = {}) {
778
+ if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines"))
779
+ throw new Error("Machine tombstones require explicit registry lifecycle operations");
780
+ if (snapshot.machines !== undefined)
781
+ validateMachines(snapshot.machines);
603
782
  const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
604
783
  if (routingErrors.length > 0) {
605
784
  throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
@@ -622,6 +801,14 @@ class PostgresTodosSyncStore {
622
801
  throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
623
802
  }
624
803
  const result = { records: 0, objectTypes: {} };
804
+ if (snapshot.machines?.length) {
805
+ if (!this.client.transaction)
806
+ throw new Error("Machine snapshot migration requires transaction(callback)");
807
+ const registry = createPostgresMachineRegistry({ query: client.query.bind(client), transaction: (fn) => fn(client) }, this.service, this.tableName, async () => {});
808
+ const receipt = await registry.execute({ action: "import", machines: snapshot.machines });
809
+ result.records += receipt.inserted + receipt.skipped;
810
+ result.objectTypes.machines = receipt.inserted + receipt.skipped;
811
+ }
625
812
  const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
626
813
  for (const entry of snapshotEntries(snapshot)) {
627
814
  if (entry.deletedAt === null)
@@ -785,6 +972,7 @@ function rowsToSnapshot(rows) {
785
972
  tasks: [],
786
973
  projects: [],
787
974
  projectMachinePaths: [],
975
+ machines: [],
788
976
  plans: [],
789
977
  agents: [],
790
978
  taskLists: [],
@@ -796,6 +984,12 @@ function rowsToSnapshot(rows) {
796
984
  for (const row of rows) {
797
985
  const payload = payloadRecord(row.payload);
798
986
  const deletedAt = stringValue(row.deleted_at);
987
+ if (row.object_type === "machines") {
988
+ if (deletedAt)
989
+ throw new Error("Snapshot contains retired machine identities; use the authoritative registry instead of silently discarding tombstones");
990
+ snapshot.machines.push(payload);
991
+ continue;
992
+ }
799
993
  if (deletedAt) {
800
994
  snapshot.tombstones ??= [];
801
995
  snapshot.tombstones.push({
@@ -1758,12 +1952,12 @@ function nullableUuid(value, field) {
1758
1952
  return null;
1759
1953
  return uuid(value, field);
1760
1954
  }
1761
- function strictObject(input, fields) {
1955
+ function strictObject(input, fields2) {
1762
1956
  if (input === null || typeof input !== "object" || Array.isArray(input)) {
1763
1957
  throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", "Request must be a JSON object");
1764
1958
  }
1765
1959
  const object = input;
1766
- const allowed = new Set(fields);
1960
+ const allowed = new Set(fields2);
1767
1961
  for (const key of Object.keys(object)) {
1768
1962
  if (!allowed.has(key)) {
1769
1963
  throw new TodosTaskSubtreeTransferError("TODOS_TASK_SUBTREE_TRANSFER_INVALID_INPUT", `Unknown field: ${key}`);
package/dist/testing.d.ts CHANGED
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * ## Why this is a shipped export and not a private fixture
5
5
  *
6
- * Every machine on the fleet exports `HASNA_TODOS_API_URL` and `HASNA_TODOS_API_KEY`,
7
- * so any test that spawns the CLI with `{ ...process.env }`, or loads the SDK in-process,
8
- * writes into the **shared hosted store**. That is not hypothetical:
6
+ * Every machine on the fleet can resolve a Todos credential — from `HASNA_TODOS_API_KEY`,
7
+ * from the macOS Keychain item `hasna.credentials.todos.api-key`, or from
8
+ * `~/.hasna/todos/config/credentials` so any test that spawns the CLI with
9
+ * `{ ...process.env }`, or loads the SDK in-process, writes into the **shared hosted
10
+ * store**. That is not hypothetical:
9
11
  *
10
12
  * - a consumer test suite leaked **1,151** rows (`seed-task-<epoch>`,
11
13
  * `Short ID resolution test`, …) between 2026-04-06 and 2026-06-21.
@@ -32,23 +34,56 @@
32
34
  * const restore = applyLocalTodosTestEnv({ HASNA_TODOS_DB_PATH: tmpDb });
33
35
  * ```
34
36
  */
37
+ /**
38
+ * The single disk credential tier the @hasna/contracts chain reads, relative to
39
+ * HOME: `~/.hasna/todos/config/credentials`. Exported so a consumer's fixture
40
+ * can name the exact file rather than rebuilding the path from a doc comment.
41
+ */
42
+ export declare const TODOS_CREDENTIALS_FILE_SEGMENTS: readonly [".hasna", "todos", "config", "credentials"];
35
43
  /**
36
44
  * Every environment variable that can point this client at a store shared with other
37
- * people. Blanking all of them is what makes a test physically unable to reach the
38
- * hosted authority — `resolveTodosCliTransport` refuses `http` without a URL and key,
39
- * and there is no fallback path that reintroduces one.
45
+ * people.
46
+ *
47
+ * Blanking them is NO LONGER SUFFICIENT ON ITS OWN, and that is the whole reason the
48
+ * local opt-in below is defaulted on. Since the credential chain moved into
49
+ * @hasna/contracts (hasna/apps#1720) a key can also arrive from the machine's login
50
+ * Keychain or from `~/.hasna/todos/config/credentials`, neither of which an env
51
+ * dictionary can blank. What makes a test physically unable to reach the hosted
52
+ * authority is therefore the pair: these variables blanked, AND `HASNA_TODOS_LOCAL=1`,
53
+ * which `resolveTodosCliTransport` answers BEFORE it consults the resolver — so no
54
+ * Keychain item and no credential file is ever read.
40
55
  *
41
56
  * Keep this in sync with the resolver by adding to it, never by deleting from it:
42
57
  * `src/testing.test.ts` fails the build if the resolver reads a `*TODOS_*` routing
43
58
  * variable that is neither listed here nor named in `LOCAL_ONLY_TODOS_ENV_KEYS`.
44
59
  */
45
- export declare const SHARED_TODOS_STORE_ENV_KEYS: readonly ["HASNA_TODOS_API_URL", "HASNA_TODOS_API_KEY", "HASNA_TODOS_API_SIGNING_KEY", "TODOS_API_URL", "TODOS_API_KEY", "HASNA_TODOS_DATABASE_URL", "TODOS_DATABASE_URL", "DATABASE_URL", "TODOS_ALLOW_ANONYMOUS"];
60
+ export declare const SHARED_TODOS_STORE_ENV_KEYS: readonly ["HASNA_TODOS_API_URL", "HASNA_TODOS_API_KEY", "HASNA_TODOS_API_SIGNING_KEY", "TODOS_API_URL", "TODOS_API_KEY", "HASNA_TODOS_SERVER_API_KEY", "HASNA_TODOS_API_KEY_OVERRIDE", "HASNA_TODOS_API_KEY_REF", "HASNA_PROFILE", "HASNA_TODOS_DATABASE_URL", "TODOS_DATABASE_URL", "DATABASE_URL", "TODOS_ALLOW_ANONYMOUS"];
46
61
  /**
47
62
  * Routing variables the resolver reads that select a store *kind* rather than a shared
48
63
  * destination. They are pinned rather than blanked, so they are exempt from the scrub
49
- * coverage assertion.
64
+ * coverage assertion. The local opt-in (`HASNA_TODOS_LOCAL=1`, alias `TODOS_LOCAL=1`)
65
+ * is the ONLY route to the on-box SQLite client mode since the 2026-09-04 fail-closed
66
+ * ruling (hasna/apps#1613); local-intent test envs default it on, remote-intent envs
67
+ * may override it back to "" to exercise the fail-closed arm.
68
+ */
69
+ export declare const LOCAL_ONLY_TODOS_ENV_KEYS: readonly ["HASNA_TODOS_LOCAL", "TODOS_LOCAL", "HASNA_TODOS_DB_PATH", "TODOS_DB_PATH", "HASNA_STATION"];
70
+ /**
71
+ * The Keychain account a test environment pins, chosen so that no item can
72
+ * exist under it.
73
+ *
74
+ * The macOS Keychain tier of the @hasna/contracts chain is AMBIENT: it runs for
75
+ * the live `process.env`, which every spawned CLI has. An env dictionary cannot
76
+ * blank a login-keychain item, so without this pin a developer's own
77
+ * `hasna.credentials.todos.api-key` — and the `api-url` item beside it — would
78
+ * be resolved by the package's own test suite: the fixture's throwaway
79
+ * authority would collide with the real one ("select different service
80
+ * authorities"), and worse, a fixture server would receive the operator's live
81
+ * fleet credential. `HASNA_STATION` decides the account the tier looks under,
82
+ * so pinning it to a name no item uses makes the tier reliably EMPTY (the
83
+ * `security` tool answers item-not-found, which is an absent tier rather than a
84
+ * failure) on a developer Mac and on CI alike.
50
85
  */
51
- export declare const LOCAL_ONLY_TODOS_ENV_KEYS: readonly ["HASNA_TODOS_DB_PATH", "TODOS_DB_PATH"];
86
+ export declare const TODOS_TEST_KEYCHAIN_ACCOUNT = "todos-test-fixture-no-such-station";
52
87
  /**
53
88
  * The retired storage-mode variables. The resolver never reads them — they are
54
89
  * inert — but a test env DELETES them so a test can never depend on a stale
@@ -59,22 +94,28 @@ export declare const REMOVED_TODOS_ENV_KEYS: readonly ["HASNA_TODOS_STORAGE_MODE
59
94
  export type SharedTodosStoreEnvKey = (typeof SHARED_TODOS_STORE_ENV_KEYS)[number];
60
95
  export type TodosTestEnv = Record<string, string | undefined>;
61
96
  /**
62
- * Deliver an explicitly supplied API key through the primary DISK tier
63
- * (`$HOME/.hasna/fleet-env/todos.env`) instead of the legacy cloud tier, and
64
- * return the env unchanged. The contracts client treats `~/.hasna/cloud` as a
65
- * NOISY deprecated fallback and prints a DEPRECATED notice to stderr whenever
66
- * the key arrives from there; a CLI subprocess test that asserts empty (or
67
- * exact) stderr then fails even though authentication succeeded. fleet-env is
68
- * the primary tier (re-read per call) and is the path the deprecation notice
69
- * itself recommends.
97
+ * Deliver an explicitly supplied API key through the DISK tier — the one the
98
+ * @hasna/contracts chain actually reads, `$HOME/.hasna/todos/config/credentials`
99
+ * — and return the env unchanged.
100
+ *
101
+ * The retired locations are not written and are not inputs anywhere:
102
+ * `~/.hasna/fleet-env/`, `~/.hasna/cloud/` and `~/.config/hasna/` were removed
103
+ * from the resolver by the 2026-09-04 home-layout ruling, so a fixture that
104
+ * still wrote one of them would deliver a credential to a file nothing reads
105
+ * and fail as "no key" long after the write appeared to succeed.
106
+ *
107
+ * The file is created 0600 because the resolver REFUSES a credential file that
108
+ * is not owner-only 0400/0600 — a group- or world-readable file is treated as
109
+ * unsafe and throws rather than being read, so a laxer mode here would turn
110
+ * every fixture using it red.
70
111
  *
71
112
  * ## This function writes a real file, so it guards itself
72
113
  *
73
114
  * It THROWS when `env.HOME` resolves to this machine's own home directory,
74
115
  * because the write would replace the operator's configured todos credential
75
116
  * with a fixture value. That is not hypothetical: on 2026-08-21 a fixture whose
76
- * env inherited `process.env.HOME` destroyed `~/.hasna/cloud/todos.env` on
77
- * station01 and cost a seat nine hours without a task system.
117
+ * env inherited `process.env.HOME` destroyed station01's own todos credential
118
+ * file and cost a seat nine hours without a task system.
78
119
  *
79
120
  * The guard lives HERE, in the function, and not only in {@link localTodosTestEnv}.
80
121
  * This symbol is exported, so `deliverTodosApiKeyViaDisk(localTodosTestEnv({ ... }))`
@@ -84,25 +125,45 @@ export type TodosTestEnv = Record<string, string | undefined>;
84
125
  *
85
126
  * Comparison is by EXACT canonical equality with the machine home, never
86
127
  * "somewhere underneath it": the artefact at risk is precisely
87
- * `$HOME/.hasna/fleet-env/todos.env`, and an under-home rule would reject
128
+ * `$HOME/.hasna/todos/config/credentials`, and an under-home rule would reject
88
129
  * fixtures that legitimately create a scratch root inside the home tree.
89
130
  *
90
131
  * A missing HOME or a missing key stays a silent no-op — nothing is written, so
91
132
  * nothing is at risk. Only the machine-home case is loud, because there the
92
133
  * caller is about to lose data and needs to be told rather than left believing
93
134
  * the credential was delivered.
135
+ *
136
+ * ## It also pins the Keychain account, and that is not a side errand
137
+ *
138
+ * The Keychain tier sits ABOVE disk. On a developer Mac with a real
139
+ * `hasna.credentials.todos.api-key` item, delivering a fixture key to disk and
140
+ * then spawning the CLI resolves the OPERATOR'S LIVE KEY instead — the fixture
141
+ * server receives a production credential, and the run can even block on a
142
+ * keychain prompt. "Deliver this key through the disk tier" is therefore only
143
+ * true if the tier above it is empty, so this stamps `HASNA_STATION` with
144
+ * {@link TODOS_TEST_KEYCHAIN_ACCOUNT} unless the caller set it themselves.
94
145
  */
95
146
  export declare function deliverTodosApiKeyViaDisk(env: TodosTestEnv): TodosTestEnv;
96
147
  /**
97
- * `process.env` with every shared-store pointer blanked and the retired
98
- * storage-mode variables deleted, so the transport resolves to a local SQLite
99
- * file. Overrides are applied last, so a test that deliberately exercises the
100
- * hosted transport against a throwaway server can still opt back in explicitly.
101
- *
102
- * Blank string, not `delete`, for the shared-store pointers: a blanked URL/key
103
- * is treated as absent, and there is no partial-config fallback. The retired
104
- * `*_STORAGE_MODE` variables are DELETED, not blanked, so a test never
105
- * inherits a stale fragment from the host environment.
148
+ * `process.env` with every shared-store pointer blanked, the retired
149
+ * storage-mode variables deleted, and the explicit local opt-in
150
+ * (`HASNA_TODOS_LOCAL=1` / `TODOS_LOCAL=1`) DEFAULTED ON, so the transport
151
+ * resolves to the on-box SQLite file. Local SQLite is no longer a default —
152
+ * the resolver fails closed without the opt-in (hasna/apps#1613) — so a
153
+ * local-intent env must carry it; a test that deliberately exercises the
154
+ * hosted transport against a throwaway server, or the fail-closed arm, can
155
+ * override it (opt-in `""`, or the API pair) because overrides are applied
156
+ * last.
157
+ *
158
+ * DELETED, never blanked. Blanking used to be the spelling for "absent", but a
159
+ * declared-but-blank authority variable is only inert where the caller
160
+ * normalises it: the Todos seam removes it before its resolver runs
161
+ * (src/lib/local-opt-in.ts), so it configures no credential and refuses
162
+ * nothing there, while a consumer that hands this dictionary straight to
163
+ * @hasna/contracts gets the resolver's loud "set but blank" refusal. Deleting
164
+ * is the one spelling that means "absent" on every path. A blank a caller
165
+ * passes in `overrides` (`{ HASNA_TODOS_API_URL: "" }`) is normalised to an
166
+ * absent key for the same reason.
106
167
  */
107
168
  export declare function localTodosTestEnv(overrides?: TodosTestEnv): TodosTestEnv;
108
169
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAMH;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,qNAa9B,CAAC;AAEX;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,mDAG5B,CAAC;AAEX;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,+FAKzB,CAAC;AAEX,MAAM,MAAM,sBAAsB,GAAG,CAAC,OAAO,2BAA2B,CAAC,CAAC,MAAM,CAAC,CAAC;AAElF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAqC9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,YAAY,GAAG,YAAY,CAmBzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,YAAiB,GAAG,YAAY,CAc5E;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,GAAE,YAAiB,GAAG,MAAM,IAAI,CAoB/E;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,GAAE,YAA0C,GAAG,IAAI,CAQ7F"}
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAMH;;;;GAIG;AACH,eAAO,MAAM,+BAA+B,uDAAwD,CAAC;AAErG;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,2BAA2B,+TA0B9B,CAAC;AAEX;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB,wGAU5B,CAAC;AAEX;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,2BAA2B,uCAAuC,CAAC;AAShF;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,+FAKzB,CAAC;AAEX,MAAM,MAAM,sBAAsB,GAAG,CAAC,OAAO,2BAA2B,CAAC,CAAC,MAAM,CAAC,CAAC;AAElF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAqC9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,YAAY,GAAG,YAAY,CA0BzE;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,YAAiB,GAAG,YAAY,CAiB5E;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,GAAE,YAAiB,GAAG,MAAM,IAAI,CAqB/E;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,GAAE,YAA0C,GAAG,IAAI,CAQ7F"}
package/dist/testing.js CHANGED
@@ -40,24 +40,38 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
40
40
  var __require = import.meta.require;
41
41
 
42
42
  // src/testing.ts
43
- import { mkdirSync, realpathSync, writeFileSync } from "fs";
43
+ import { chmodSync, mkdirSync, realpathSync, writeFileSync } from "fs";
44
44
  import { homedir } from "os";
45
- import { join, resolve } from "path";
45
+ import { dirname, join, resolve } from "path";
46
+ var TODOS_CREDENTIALS_FILE_SEGMENTS = [".hasna", "todos", "config", "credentials"];
46
47
  var SHARED_TODOS_STORE_ENV_KEYS = [
47
48
  "HASNA_TODOS_API_URL",
48
49
  "HASNA_TODOS_API_KEY",
49
50
  "HASNA_TODOS_API_SIGNING_KEY",
50
51
  "TODOS_API_URL",
51
52
  "TODOS_API_KEY",
53
+ "HASNA_TODOS_SERVER_API_KEY",
54
+ "HASNA_TODOS_API_KEY_OVERRIDE",
55
+ "HASNA_TODOS_API_KEY_REF",
56
+ "HASNA_PROFILE",
52
57
  "HASNA_TODOS_DATABASE_URL",
53
58
  "TODOS_DATABASE_URL",
54
59
  "DATABASE_URL",
55
60
  "TODOS_ALLOW_ANONYMOUS"
56
61
  ];
57
62
  var LOCAL_ONLY_TODOS_ENV_KEYS = [
63
+ "HASNA_TODOS_LOCAL",
64
+ "TODOS_LOCAL",
58
65
  "HASNA_TODOS_DB_PATH",
59
- "TODOS_DB_PATH"
66
+ "TODOS_DB_PATH",
67
+ "HASNA_STATION"
60
68
  ];
69
+ var TODOS_TEST_KEYCHAIN_ACCOUNT = "todos-test-fixture-no-such-station";
70
+ var LOCAL_OPT_IN_DEFAULT = {
71
+ HASNA_TODOS_LOCAL: "1",
72
+ TODOS_LOCAL: "1",
73
+ HASNA_STATION: TODOS_TEST_KEYCHAIN_ACCOUNT
74
+ };
61
75
  var REMOVED_TODOS_ENV_KEYS = [
62
76
  "HASNA_TODOS_STORAGE_MODE",
63
77
  "HASNA_TODOS_MODE",
@@ -91,21 +105,28 @@ function deliverTodosApiKeyViaDisk(env) {
91
105
  return env;
92
106
  const target = canonicalisePath(home);
93
107
  if (machineHomeCandidates().includes(target)) {
94
- throw new Error(`TODOS_FIXTURE_HOME_IS_MACHINE_HOME: refusing to write ${join(target, ".hasna", "fleet-env", "todos.env")} \u2014 ` + "HOME resolves to this machine's real home directory, so this write would replace the machine's " + "configured todos credential with a fixture value. Pass a throwaway home, e.g. " + 'HOME: mkdtempSync(join(tmpdir(), "todos-fixture-")).');
108
+ throw new Error(`TODOS_FIXTURE_HOME_IS_MACHINE_HOME: refusing to write ${join(target, ...TODOS_CREDENTIALS_FILE_SEGMENTS)} \u2014 ` + "HOME resolves to this machine's real home directory, so this write would replace the machine's " + "configured todos credential with a fixture value. Pass a throwaway home, e.g. " + 'HOME: mkdtempSync(join(tmpdir(), "todos-fixture-")).');
95
109
  }
96
- const dir = join(home, ".hasna", "fleet-env");
97
- mkdirSync(dir, { recursive: true });
98
- writeFileSync(join(dir, "todos.env"), `HASNA_TODOS_API_KEY=${apiKey}
99
- `);
110
+ if ((env.HASNA_STATION ?? "").trim() === "")
111
+ env.HASNA_STATION = TODOS_TEST_KEYCHAIN_ACCOUNT;
112
+ const file = join(home, ...TODOS_CREDENTIALS_FILE_SEGMENTS);
113
+ mkdirSync(dirname(file), { recursive: true });
114
+ writeFileSync(file, `HASNA_TODOS_API_KEY=${apiKey}
115
+ `, { mode: 384 });
116
+ chmodSync(file, 384);
100
117
  return env;
101
118
  }
102
119
  function localTodosTestEnv(overrides = {}) {
103
120
  const env = { ...process.env };
104
121
  for (const key of SHARED_TODOS_STORE_ENV_KEYS)
105
- env[key] = "";
122
+ delete env[key];
106
123
  for (const key of REMOVED_TODOS_ENV_KEYS)
107
124
  delete env[key];
108
- const result = { ...env, ...overrides };
125
+ const result = { ...env, ...LOCAL_OPT_IN_DEFAULT, ...overrides };
126
+ for (const key of SHARED_TODOS_STORE_ENV_KEYS) {
127
+ if ((result[key] ?? "").trim() === "")
128
+ delete result[key];
129
+ }
109
130
  if (overrides.HOME && result.HASNA_TODOS_API_KEY) {
110
131
  deliverTodosApiKeyViaDisk(result);
111
132
  }
@@ -115,6 +136,7 @@ function applyLocalTodosTestEnv(overrides = {}) {
115
136
  const next = localTodosTestEnv(overrides);
116
137
  const touched = [
117
138
  ...SHARED_TODOS_STORE_ENV_KEYS,
139
+ ...LOCAL_ONLY_TODOS_ENV_KEYS,
118
140
  ...REMOVED_TODOS_ENV_KEYS,
119
141
  ...Object.keys(overrides)
120
142
  ];
@@ -148,6 +170,8 @@ export {
148
170
  deliverTodosApiKeyViaDisk,
149
171
  assertLocalTodosTestEnv,
150
172
  applyLocalTodosTestEnv,
173
+ TODOS_TEST_KEYCHAIN_ACCOUNT,
174
+ TODOS_CREDENTIALS_FILE_SEGMENTS,
151
175
  SHARED_TODOS_STORE_ENV_KEYS,
152
176
  REMOVED_TODOS_ENV_KEYS,
153
177
  LOCAL_ONLY_TODOS_ENV_KEYS