@hasna/todos 0.15.51 → 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.
- package/CHANGELOG.md +1884 -0
- package/README.md +314 -37
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai.d.ts.map +1 -1
- package/dist/cli/cloud-router.d.ts +99 -51
- package/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/agent-commands.d.ts.map +1 -1
- package/dist/cli/commands/config-serve-commands.d.ts.map +1 -1
- package/dist/cli/commands/delegate.d.ts.map +1 -1
- package/dist/cli/commands/environment-snapshots.d.ts.map +1 -1
- package/dist/cli/commands/machines-api.d.ts +4 -0
- package/dist/cli/commands/machines-api.d.ts.map +1 -0
- package/dist/cli/commands/machines.d.ts.map +1 -1
- package/dist/cli/commands/mcp-hooks-commands.d.ts.map +1 -1
- package/dist/cli/commands/plan-commands.d.ts +3 -0
- package/dist/cli/commands/plan-commands.d.ts.map +1 -0
- package/dist/cli/commands/plan-template-commands.d.ts.map +1 -1
- package/dist/cli/commands/pr-group-commands.d.ts.map +1 -1
- package/dist/cli/commands/project-commands.d.ts +15 -0
- package/dist/cli/commands/project-commands.d.ts.map +1 -1
- package/dist/cli/commands/query-commands.d.ts.map +1 -1
- package/dist/cli/commands/storage-commands.d.ts.map +1 -1
- package/dist/cli/commands/task-list-commands.d.ts +3 -0
- package/dist/cli/commands/task-list-commands.d.ts.map +1 -0
- package/dist/cli/commands/template-commands.d.ts +3 -0
- package/dist/cli/commands/template-commands.d.ts.map +1 -0
- package/dist/cli/helpers.d.ts.map +1 -1
- package/dist/cli/index.js +15156 -11088
- package/dist/cli/machine-api.d.ts +7 -0
- package/dist/cli/machine-api.d.ts.map +1 -0
- package/dist/cli/plan-read-api.d.ts +6 -0
- package/dist/cli/plan-read-api.d.ts.map +1 -0
- package/dist/cli/project-api.d.ts +119 -0
- package/dist/cli/project-api.d.ts.map +1 -0
- package/dist/cli/stage-a.d.ts +34 -4
- package/dist/cli/stage-a.d.ts.map +1 -1
- package/dist/cli/task-query-api.d.ts +21 -0
- package/dist/cli/task-query-api.d.ts.map +1 -0
- package/dist/cli/template-api.d.ts +7 -0
- package/dist/cli/template-api.d.ts.map +1 -0
- package/dist/contracts.js +836 -266
- package/dist/db/builtin-templates.d.ts +2 -33
- package/dist/db/builtin-templates.d.ts.map +1 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/handoffs.d.ts.map +1 -1
- package/dist/db/machines.d.ts.map +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/plans.d.ts.map +1 -1
- package/dist/db/retrospectives.d.ts.map +1 -1
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/task-lists.d.ts.map +1 -1
- package/dist/db/task-runs.d.ts +2 -0
- package/dist/db/task-runs.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3863 -1708
- package/dist/lib/assignee-validation.d.ts.map +1 -1
- package/dist/lib/builtin-template-library.d.ts +32 -0
- package/dist/lib/builtin-template-library.d.ts.map +1 -0
- package/dist/lib/cli-reference.d.ts.map +1 -1
- package/dist/lib/config.d.ts +10 -13
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/db-backup.d.ts +1 -1
- package/dist/lib/db-backup.d.ts.map +1 -1
- package/dist/lib/env.d.ts +18 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/environment-snapshots.d.ts.map +1 -1
- package/dist/lib/gatherer.d.ts.map +1 -1
- package/dist/lib/headless-boundaries.d.ts +4 -3
- package/dist/lib/headless-boundaries.d.ts.map +1 -1
- package/dist/lib/local-encryption.d.ts +12 -0
- package/dist/lib/local-encryption.d.ts.map +1 -1
- package/dist/lib/local-opt-in.d.ts +90 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/paths.d.ts +9 -0
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/lib/plan-artifacts.d.ts +1 -0
- package/dist/lib/plan-artifacts.d.ts.map +1 -1
- package/dist/lib/plan-client-boundary.d.ts +2 -0
- package/dist/lib/plan-client-boundary.d.ts.map +1 -0
- package/dist/lib/plan-schedule.d.ts +6 -0
- package/dist/lib/plan-schedule.d.ts.map +1 -0
- package/dist/lib/project-panel.d.ts +6 -0
- package/dist/lib/project-panel.d.ts.map +1 -1
- package/dist/lib/project-receipt.d.ts +3 -0
- package/dist/lib/project-receipt.d.ts.map +1 -0
- package/dist/lib/sandbox-profiles.d.ts.map +1 -1
- package/dist/lib/shared-plan-artifacts.d.ts +14 -0
- package/dist/lib/shared-plan-artifacts.d.ts.map +1 -0
- package/dist/lib/sync.d.ts.map +1 -1
- package/dist/lib/task-list-client-boundary.d.ts +3 -0
- package/dist/lib/task-list-client-boundary.d.ts.map +1 -0
- package/dist/lib/task-routing.d.ts.map +1 -1
- package/dist/lib/template-client-boundary.d.ts +2 -0
- package/dist/lib/template-client-boundary.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +45195 -40368
- package/dist/mcp/plan-api.d.ts +6 -0
- package/dist/mcp/plan-api.d.ts.map +1 -0
- package/dist/mcp/remote-authority.d.ts +49 -0
- package/dist/mcp/remote-authority.d.ts.map +1 -0
- package/dist/mcp/task-coordination-api.d.ts +15 -0
- package/dist/mcp/task-coordination-api.d.ts.map +1 -0
- package/dist/mcp/task-list-api.d.ts +8 -0
- package/dist/mcp/task-list-api.d.ts.map +1 -0
- package/dist/mcp/token-utils.d.ts.map +1 -1
- package/dist/mcp/tools/environment-snapshots.d.ts.map +1 -1
- package/dist/mcp/tools/machines.d.ts.map +1 -1
- package/dist/mcp/tools/task-project-tools.d.ts.map +1 -1
- package/dist/mcp/tools/task-resources.d.ts.map +1 -1
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +45 -19
- package/dist/project-registration.js +2830 -557
- package/dist/registry.js +837 -267
- package/dist/release-provenance.json +6 -6
- package/dist/sdk/client.d.ts +51 -4
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +1209 -1080
- package/dist/sdk/resolve.d.ts +91 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +219 -3
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/auth-posture.d.ts +66 -7
- package/dist/server/auth-posture.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud.d.ts +2 -1
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +20556 -15533
- package/dist/server/openapi.d.ts +1341 -83
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/port.d.ts +2 -2
- package/dist/server/port.d.ts.map +1 -1
- package/dist/server/routes.d.ts +3 -6
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/serve.d.ts +2 -6
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/server/v1.d.ts +3 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/atomic-project-migration.d.ts +53 -0
- package/dist/storage/atomic-project-migration.d.ts.map +1 -0
- package/dist/storage/index.d.ts +3 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +61 -0
- package/dist/storage/interfaces.d.ts.map +1 -1
- package/dist/storage/machine-registry.d.ts +36 -0
- package/dist/storage/machine-registry.d.ts.map +1 -0
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/postgres-sync.d.ts +2 -2
- package/dist/storage/postgres-sync.d.ts.map +1 -1
- package/dist/storage/run-artifact-creation-upload.d.ts +50 -0
- package/dist/storage/run-artifact-creation-upload.d.ts.map +1 -0
- package/dist/storage/s3-artifact-sync.d.ts +18 -0
- package/dist/storage/s3-artifact-sync.d.ts.map +1 -1
- package/dist/storage/sqlite-snapshot.d.ts +2 -0
- package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +2695 -322
- package/dist/task-manifest/postgres.d.ts +1 -1
- package/dist/task-manifest/sqlite.d.ts +1 -1
- package/dist/task-manifest.js +244 -73
- package/dist/task-subtree-transfer.js +198 -4
- package/dist/testing.d.ts +90 -29
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +34 -10
- package/dist/types/index.d.ts +40 -4
- package/dist/types/index.d.ts.map +1 -1
- package/docs/PLAN_API.md +93 -0
- package/docs/TASK_LIST_API.md +58 -0
- package/docs/TASK_QUERY_API.md +14 -0
- package/docs/TEMPLATE_API.md +18 -0
- package/docs/native-storage.md +405 -0
- package/package.json +19 -16
- package/postinstall.js +52 -1
- package/dashboard/dist/assets/index-BNQ4gJua.js +0 -342
- package/dashboard/dist/assets/index-DjzvHUWt.css +0 -1
- package/dashboard/dist/index.html +0 -13
- 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,
|
|
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(
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
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
|
|
63
|
-
*
|
|
64
|
-
* return the env unchanged.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* the
|
|
69
|
-
*
|
|
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
|
|
77
|
-
*
|
|
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/
|
|
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
|
|
98
|
-
* storage-mode variables deleted,
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
/**
|
package/dist/testing.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA
|
|
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,
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|