codemem 0.46.2 → 0.47.0-beta.2
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 +3 -1
- package/dist/index.js +1390 -124
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { ContextOnlyReplayError, DEDUP_KEY_BACKFILL_JOB, DEFAULT_COORDINATOR_DB_PATH, DedupKeyBackfillRunner, DeviceIdentityError, EXTRACTION_BENCHMARK_QUALITY_WEIGHTS, MUTATING_TOOL_NAMES, MemoryStore, ObserverClient, ObserverOutputError, ObserverOutputTransportError, PROMPT_TRANSPORT_PROTOCOL_RANGE, REF_BACKFILL_JOB, RawEventIngestValidationError, RawEventSweeper, RefBackfillRunner, SCOPE_BACKFILL_JOB, SESSION_CONTEXT_BACKFILL_JOB, SUMMARY_DEDUP_BACKFILL_JOB, ScopeBackfillRunner, SessionContextBackfillRunner, SummaryDedupBackfillRunner, SyncRetentionRunner, TRUSTED_HOOK_MAPPER_OPTIONS, VERSION, VectorModelMigrationRunner, aiBackfillStructuredContent, applyBootstrapSnapshot, applyDistillRule, arePromptTransportProtocolRangesCompatible, atomicReplaceConfigFile, backfillMemoryDedupKeys, backfillNarrativeFromBody, backfillTagsText, backfillVectors, buildBaseUrl, buildDirectPeerAuthHeaders, buildDistillReport, buildPiFlushSignalFromEvent, buildRawEventEnvelopeFromHook, buildRawEventEnvelopeFromPiEvent, buildTieredObserverSelection, buildViewerIdentityTarget, classifyPromptTransportFailure, clonePromptPackAttempt, collectOperationalStatus, compareMemoryRoleReports, connect, connectReadOnly, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnrollDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorUpdateScopeAction, createBetterSqliteCoordinatorApp, deactivateLowSignalMemories, deactivateLowSignalObservations, dedupNearDuplicateMemories, describePiObserverStatus, detectInstallKind, draftDistillRule, ensureDeviceIdentity, ensureSchemaBootstrapped, estimateExtractionModelCost, exportMemories, extractApplyPatchPaths, extractionReplayObserverIdentity, fetchAllSnapshotPages, fingerprintPublicKey, flushRawEvents, formatHostPort, getAttributionDiagnostics, getCodememConfigPath, getExtractionBenchmarkProfile, getExtractionModelPricing, getInjectionEvalScenarioPack, getInjectionEvalScenarioPrompts, getMaintenanceJob, getMemoryArtifactReport, getMemoryRoleReport, getRawEventRelinkPlan, getRawEventRelinkReport, getRawEventStatus, getSemanticIndexDiagnostics, getSessionExtractionEval, getSessionExtractionEvalScenario, getUpdateStatus, getWorkspaceCodememConfigPath, hasPendingDedupKeyBackfill, hasPendingRefBackfill, hasPendingScopeBackfill, hasPendingSessionContextBackfill, hasPendingSummaryDedupBackfill, hasUnsyncedSharedMemoryChanges, importMemories, ingestRawEvents, initDatabase, isAutomaticRecallMeasurement, isEmbeddingDisabled, isExplicitUpdateInstallEligible, isReleaseVersionForChannel, judgeDistillReport, listMaintenanceJobs, listPerPeerScopeSyncState, listRetentionScopeIds, loadObserverConfig, loadPublicKey, loadSqliteVec, mdnsEnabled, mutateCodememConfigFile, normalizePromptTransportProtocolRange, observerBaseUrlForProviderOverride, planReplicationOpsAgePrune, probeCodememViewerLiveness, probeCodememViewerLiveness as probeCodememViewerLiveness$1, probeRequiredNativeRuntime, projectMatchesFilter, promptPackArtifactFingerprint, pruneReplicationOpsUntilCaughtUp, rawEventsGate, readCodememConfigFile, readCodememConfigFileAtPath, readCoordinatorSyncConfig, readImportPayload, recordAutomaticRecall, recordPromptPackArtifacts, recordPromptPackTerminal, recordRetrievalSurface, renderUnifiedDiff, replayBatchExtraction, replayBatchExtractionWithTierRouting, requestJson, resolveCodememConfigPath, resolveComparablePath, resolveDbPath, resolveHookProject, resolvePiAgentDir, resolvePiObserverConfig, resolveProject, resolveProjectRoot, resolveRetrievalSession, retryRawEventFailures, runSyncDaemon, runSyncPass, scanSecretsRetroactive, schema, scoreExtractionBenchmarkOutput, setPeerProjectFilter, syncPassPreflight, tryUpdateRetrievalDelivery, updatePeerAddresses, vacuumDatabase, viewerUrl, writeCodememConfigFile } from "@codemem/core";
|
|
2
|
+
import { ACCOUNT_CONNECTION_MIGRATION_STATEMENTS, ACCOUNT_UNLINK_MIGRATION_STATEMENTS, AUTH_LINK_AUTHORITY_CUTOVER_SQL, ContextOnlyReplayError, CoordinatorAccountLinkError, DEDUP_KEY_BACKFILL_JOB, DEFAULT_COORDINATOR_DB_PATH, DedupKeyBackfillRunner, DeviceIdentityError, EXTRACTION_BENCHMARK_QUALITY_WEIGHTS, MUTATING_TOOL_NAMES, MemoryStore, ObserverClient, ObserverOutputError, ObserverOutputTransportError, OwnerProfileEnrollmentError, PROMPT_TRANSPORT_PROTOCOL_RANGE, REF_BACKFILL_JOB, RawEventIngestValidationError, RawEventSweeper, RefBackfillRunner, RemoteCoordinatorRequestError, SCOPE_BACKFILL_JOB, SESSION_CONTEXT_BACKFILL_JOB, SUMMARY_DEDUP_BACKFILL_JOB, ScopeBackfillRunner, SessionContextBackfillRunner, SummaryDedupBackfillRunner, SyncRetentionRunner, TRUSTED_HOOK_MAPPER_OPTIONS, VERSION, VectorModelMigrationRunner, aiBackfillStructuredContent, applyBootstrapSnapshot, applyDistillRule, arePromptTransportProtocolRangesCompatible, atomicReplaceConfigFile, backfillMemoryDedupKeys, backfillNarrativeFromBody, backfillTagsText, backfillVectors, buildBaseUrl, buildDirectPeerAuthHeaders, buildDistillReport, buildPiFlushSignalFromEvent, buildRawEventEnvelopeFromHook, buildRawEventEnvelopeFromPiEvent, buildTieredObserverSelection, buildViewerIdentityTarget, classifyPromptTransportFailure, clonePromptPackAttempt, collectOperationalStatus, compareMemoryRoleReports, connect, connectReadOnly, coordinatorAuthControllerReviewAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnrollDeviceAction, coordinatorGrantIdentityGroupAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorQualifyOwnerAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorReviewInviteAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeDeviceAction, coordinatorRevokeScopeMembershipAction, coordinatorUpdateScopeAction, createBetterSqliteCoordinatorApp, deactivateLowSignalMemories, deactivateLowSignalObservations, dedupNearDuplicateMemories, describePiObserverStatus, detectInstallKind, draftDistillRule, enrollOwnerProfile, ensureDeviceIdentity, ensureSchemaBootstrapped, estimateExtractionModelCost, expandUserPath, exportMemories, extractApplyPatchPaths, extractionReplayObserverIdentity, fetchAllSnapshotPages, fingerprintPublicKey, flushRawEvents, formatHostPort, getAttributionDiagnostics, getCodememConfigPath, getExtractionBenchmarkProfile, getExtractionModelPricing, getInjectionEvalScenarioPack, getInjectionEvalScenarioPrompts, getMaintenanceJob, getMemoryArtifactReport, getMemoryRoleReport, getRawEventRelinkPlan, getRawEventRelinkReport, getRawEventStatus, getSemanticIndexDiagnostics, getSessionExtractionEval, getSessionExtractionEvalScenario, getUpdateStatus, getWorkspaceCodememConfigPath, hasPendingDedupKeyBackfill, hasPendingRefBackfill, hasPendingScopeBackfill, hasPendingSessionContextBackfill, hasPendingSummaryDedupBackfill, hasUnsyncedSharedMemoryChanges, importMemories, importPiSessions, ingestRawEvents, initDatabase, isAutomaticRecallMeasurement, isCoordinatorControllerAttestationReference, isEmbeddingDisabled, isExplicitUpdateInstallEligible, isReleaseVersionForChannel, judgeDistillReport, linkCoordinatorAccount, listMaintenanceJobs, listPerPeerScopeSyncState, listRetentionScopeIds, loadObserverConfig, loadPublicKey, loadRuntimeSigningPublicKey, loadSqliteVec, mdnsEnabled, mutateCodememConfigFile, normalizeCoordinatorAccountLinkOrigin, normalizePromptTransportProtocolRange, observerBaseUrlForProviderOverride, planReplicationOpsAgePrune, probeCodememViewerLiveness, probeCodememViewerLiveness as probeCodememViewerLiveness$1, probeRequiredNativeRuntime, projectMatchesFilter, promptPackArtifactFingerprint, pruneReplicationOpsUntilCaughtUp, rawEventsGate, readAccountLinkDevice, readCodememConfigFile, readCodememConfigFileAtPath, readCoordinatorAccountLinkStatus, readCoordinatorOwnerReviewLocalEvidence, readCoordinatorSyncConfig, readImportPayload, recordAutomaticRecall, recordPromptPackArtifacts, recordPromptPackTerminal, recordRetrievalSurface, renderUnifiedDiff, replayBatchExtraction, replayBatchExtractionWithTierRouting, requestJson, resolveCodememConfigPath, resolveComparablePath, resolveDbPath, resolveHookProject, resolveKeyPaths, resolvePiAgentDir, resolvePiObserverConfig, resolveProject, resolveProjectRoot, resolveRetrievalSession, retryRawEventFailures, runSyncDaemon, runSyncPass, scanSecretsRetroactive, schema, scoreExtractionBenchmarkOutput, searchPiSessions, setPeerProjectFilter, syncPassPreflight, tryUpdateRetrievalDelivery, updatePeerAddresses, upgradeCoordinatorAccountSchemaFile, upgradeCoordinatorAuthSchemaFile, vacuumDatabase, viewerUrl, writeCodememConfigFile } from "@codemem/core";
|
|
3
3
|
import { Command, Option } from "commander";
|
|
4
4
|
import omelette from "omelette";
|
|
5
5
|
import { randomInt, randomUUID } from "node:crypto";
|
|
@@ -2583,6 +2583,937 @@ whereCmd.action((opts) => {
|
|
|
2583
2583
|
});
|
|
2584
2584
|
configCommand.addCommand(whereCmd);
|
|
2585
2585
|
//#endregion
|
|
2586
|
+
//#region src/commands/coordinator-auth-schema.ts
|
|
2587
|
+
function failure$4(options, message, exitCode, errorCode = "auth_schema_upgrade_failed") {
|
|
2588
|
+
if (options.json) {
|
|
2589
|
+
emitJsonError(exitCode === 2 ? "usage_error" : errorCode, message, exitCode);
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
p.log.error(`Coordinator auth upgrade stopped: ${message}`);
|
|
2593
|
+
process.exitCode = exitCode;
|
|
2594
|
+
}
|
|
2595
|
+
function upgrade(options) {
|
|
2596
|
+
const path = resolveDbOpt(options);
|
|
2597
|
+
if (options.exportSql && path) {
|
|
2598
|
+
failure$4(options, "Use --export-sql without --db-path; export opens no database.", 2);
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
2601
|
+
if (!options.exportSql && !path) {
|
|
2602
|
+
failure$4(options, "Select an existing coordinator database with --db-path. Pause auth writers, drain attempts and back up the database first.", 2);
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
try {
|
|
2606
|
+
if (options.exportSql) {
|
|
2607
|
+
const sql = `${AUTH_LINK_AUTHORITY_CUTOVER_SQL}\n`;
|
|
2608
|
+
process.stdout.write(options.json ? `${JSON.stringify({
|
|
2609
|
+
sql,
|
|
2610
|
+
applied: false
|
|
2611
|
+
})}\n` : sql);
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
if (!path) return;
|
|
2615
|
+
upgradeCoordinatorAuthSchemaFile(path);
|
|
2616
|
+
process.stdout.write(options.json ? `${JSON.stringify({ upgraded: true })}\n` : "Coordinator authentication schema upgraded.\n");
|
|
2617
|
+
} catch (error) {
|
|
2618
|
+
failure$4(options, `Upgrade aborted without committing changes. Check that attempts are drained and the legacy schema and provenance match the reviewed source. ${error instanceof Error ? error.message : "Coordinator auth upgrade failed."}`, 1);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
function buildCoordinatorAuthSchemaCommand() {
|
|
2622
|
+
const command = new Command("upgrade-auth-schema").configureHelp(helpStyle).description("Upgrade local coordinator auth or export reviewed D1 SQL").option("-e, --export-sql", "print D1 cutover SQL without opening a database");
|
|
2623
|
+
addDbOption(command);
|
|
2624
|
+
addJsonOption(command);
|
|
2625
|
+
command.action(upgrade);
|
|
2626
|
+
return command;
|
|
2627
|
+
}
|
|
2628
|
+
var accountPrerequisites = "Pause auth writers, drain pending attempts and back up the selected coordinator database first. This command does not pause writers or create a backup.";
|
|
2629
|
+
function hasDatabaseSelection(options) {
|
|
2630
|
+
return options.dbPath !== void 0 || options.db !== void 0;
|
|
2631
|
+
}
|
|
2632
|
+
function exportAccountSchema(options) {
|
|
2633
|
+
const statements = options.deviceUnlink ? ACCOUNT_UNLINK_MIGRATION_STATEMENTS : ACCOUNT_CONNECTION_MIGRATION_STATEMENTS;
|
|
2634
|
+
const text = `-- ${accountPrerequisites}\n-- Execute in ONE atomic D1 batch; do not split into autocommitted requests. Nothing has been applied.\n${statements.map((s) => `${s.trim().replace(/;$/u, "")};`).join("\n")}\n`;
|
|
2635
|
+
process.stdout.write(options.json ? `${JSON.stringify({
|
|
2636
|
+
statements,
|
|
2637
|
+
applied: false
|
|
2638
|
+
})}\n` : text);
|
|
2639
|
+
}
|
|
2640
|
+
async function upgradeAccount(options) {
|
|
2641
|
+
if (options.deviceUnlink && !options.exportSql) {
|
|
2642
|
+
failure$4(options, "Use --device-unlink only with --export-sql; local upgrades detect the installed schema.", 2, "account_schema_upgrade_failed");
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
const path = resolveDbOpt(options);
|
|
2646
|
+
const selected = hasDatabaseSelection(options);
|
|
2647
|
+
if (options.exportSql && selected || !options.exportSql && !path) {
|
|
2648
|
+
failure$4(options, options.exportSql ? "Use --export-sql without --db-path; export opens no database." : `Select an existing coordinator database with --db-path. ${accountPrerequisites}`, 2, "account_schema_upgrade_failed");
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
try {
|
|
2652
|
+
if (options.exportSql) {
|
|
2653
|
+
exportAccountSchema(options);
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
if (!path) return;
|
|
2657
|
+
if (!options.json) process.stderr.write(`${accountPrerequisites}\n`);
|
|
2658
|
+
await upgradeCoordinatorAccountSchemaFile(path);
|
|
2659
|
+
process.stdout.write(options.json ? `${JSON.stringify({ upgraded: true })}\n` : "Coordinator account schema upgraded.\n");
|
|
2660
|
+
} catch (error) {
|
|
2661
|
+
failure$4(options, `Account schema upgrade stopped. Inspect the selected database before retrying. ${error instanceof Error ? error.message : "Coordinator account schema upgrade failed."}`, 1, "account_schema_upgrade_failed");
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
function buildCoordinatorAccountSchemaCommand() {
|
|
2665
|
+
const command = new Command("upgrade-account-schema").configureHelp(helpStyle).description("Explicitly install optional coordinator account management on an existing V2 database").option("--device-unlink", "export only the delta for the prior account-management schema (requires --export-sql)").option("-e, --export-sql", "export statement boundaries for one atomic D1 batch without opening a database").addHelpText("after", `\n${accountPrerequisites}\nD1 export does not apply SQL or verify remote execution limits.\n`);
|
|
2666
|
+
addDbOption(command);
|
|
2667
|
+
addJsonOption(command);
|
|
2668
|
+
command.action(upgradeAccount);
|
|
2669
|
+
return command;
|
|
2670
|
+
}
|
|
2671
|
+
//#endregion
|
|
2672
|
+
//#region src/commands/coordinator-enroll-owner.ts
|
|
2673
|
+
var CAPTURE_WARNING = "Stop OpenCode capture, the Viewer and the sync daemon before preparing or resuming. Keep them stopped until Verified coordinator Identity is shown. Any capture/history change during the browser ceremony can stop local adoption after the server commits, leaving DB and config separate and requiring operator review.";
|
|
2674
|
+
function showPrivateBrowserLink$1(privateUrl) {
|
|
2675
|
+
p.log.warn("Keep this browser link private. Open it to review the Google account and device, then return here to finish. No Team or Project access is added.", { output: process.stderr });
|
|
2676
|
+
p.log.message(privateUrl, { output: process.stderr });
|
|
2677
|
+
}
|
|
2678
|
+
function showAccountAdmissionLink(url, request) {
|
|
2679
|
+
if (!request) throw new OwnerProfileEnrollmentError("review_required");
|
|
2680
|
+
p.log.warn("Open this account admission page while signed in to the intended account. Upload the private capability-request.json file below unchanged, compare the local fingerprint, and review the device. Keep this command running and capture stopped; browser confirmation alone does not finish enrollment. No Team or Project access is added.", { output: process.stderr });
|
|
2681
|
+
p.log.message(`${url}\nPrivate request file: ${request.requestFile}\nDevice: ${request.deviceId}\nLocal fingerprint: ${request.fingerprint}`, { output: process.stderr });
|
|
2682
|
+
}
|
|
2683
|
+
function admissionOptions(options) {
|
|
2684
|
+
if (options.accountAdmission) return {
|
|
2685
|
+
admissionMode: "account",
|
|
2686
|
+
onBrowserStart: showAccountAdmissionLink
|
|
2687
|
+
};
|
|
2688
|
+
return {
|
|
2689
|
+
admissionMode: "operator",
|
|
2690
|
+
onBrowserStart: showPrivateBrowserLink$1
|
|
2691
|
+
};
|
|
2692
|
+
}
|
|
2693
|
+
function reportUsageError(options, message) {
|
|
2694
|
+
if (options.json) emitJsonError("usage_error", message, 2);
|
|
2695
|
+
else {
|
|
2696
|
+
p.log.error(message, { output: process.stderr });
|
|
2697
|
+
process.exitCode = 2;
|
|
2698
|
+
}
|
|
2699
|
+
return false;
|
|
2700
|
+
}
|
|
2701
|
+
function usage(options) {
|
|
2702
|
+
if (options.prepare === options.resume || options.prepare && (options.capability || options.reviewDigest || options.accountAdmission) || options.accountAdmission && options.capability !== void 0 || options.resume && !options.reviewDigest || options.resume && (options.json || !process.stderr.isTTY)) return reportUsageError(options, "Use --prepare first, then review its device, current Identity, capture counts and digest. Resume interactively with --resume --review-digest; use either --capability for operator admission or --account-admission for signed-in account admission, never both.");
|
|
2703
|
+
if (options.capability !== void 0 && !/^[A-Za-z0-9._-]{1,512}$/.test(options.capability)) return reportUsageError(options, "Supply --capability exactly as issued, without whitespace or unsupported characters, and at most 512 characters. The prepared attempt has not been consumed.");
|
|
2704
|
+
if (options.reviewDigest !== void 0 && !/^[0-9a-f]{64}$/.test(options.reviewDigest)) return reportUsageError(options, "Supply --review-digest as the exact 64-character lowercase hexadecimal digest from --prepare. Enrollment has not started.");
|
|
2705
|
+
return environmentUsage(options);
|
|
2706
|
+
}
|
|
2707
|
+
function environmentUsage(options) {
|
|
2708
|
+
const overrides = [
|
|
2709
|
+
"CODEMEM_SYNC_COORDINATOR_URL",
|
|
2710
|
+
"CODEMEM_SYNC_COORDINATOR_GROUP",
|
|
2711
|
+
"CODEMEM_SYNC_COORDINATOR_GROUPS",
|
|
2712
|
+
"CODEMEM_WORKSPACE_ID",
|
|
2713
|
+
"CODEMEM_ACTOR_ID",
|
|
2714
|
+
"CODEMEM_DEVICE_ID",
|
|
2715
|
+
"CODEMEM_ACTOR_DISPLAY_NAME"
|
|
2716
|
+
].filter((name) => process.env[name] !== void 0);
|
|
2717
|
+
if (overrides.length) return reportUsageError(options, `Unset ${overrides.join(" and ")} before owner enrollment. Use --coordinator and the group argument, or the selected config; environment overrides cannot be adopted safely.`);
|
|
2718
|
+
if (process.env.CODEMEM_SYNC_KEY_STORE?.trim().toLowerCase() === "keychain") return reportUsageError(options, "CODEMEM_SYNC_KEY_STORE=keychain is not supported for owner enrollment. Keep signing keys unchanged; select a supported existing file-key profile.");
|
|
2719
|
+
return true;
|
|
2720
|
+
}
|
|
2721
|
+
function coordinatorOriginUsage(options, value) {
|
|
2722
|
+
if (typeof value !== "string") return reportUsageError(options, "Set sync_coordinator_url to a valid HTTPS origin string before owner enrollment.");
|
|
2723
|
+
try {
|
|
2724
|
+
const url = new URL(value);
|
|
2725
|
+
if (value.trim() === value && url.protocol === "https:" && !url.username && !url.password && (value === url.origin || value === `${url.origin}/`)) return true;
|
|
2726
|
+
} catch {}
|
|
2727
|
+
return reportUsageError(options, "Set --coordinator or sync_coordinator_url to an HTTPS origin without credentials, paths, query strings, fragments, or surrounding whitespace.");
|
|
2728
|
+
}
|
|
2729
|
+
function groupUsage(options, groupId) {
|
|
2730
|
+
if (groupId.length > 0 && groupId.length <= 256 && groupId.trim() === groupId && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(groupId)) return true;
|
|
2731
|
+
return reportUsageError(options, "Use a nonempty group ID of at most 256 characters, without surrounding whitespace or control characters.");
|
|
2732
|
+
}
|
|
2733
|
+
function pathEnvironmentUsage(options, selected) {
|
|
2734
|
+
for (const [name, path, flag] of [
|
|
2735
|
+
[
|
|
2736
|
+
"CODEMEM_CONFIG",
|
|
2737
|
+
selected.config,
|
|
2738
|
+
"--config"
|
|
2739
|
+
],
|
|
2740
|
+
[
|
|
2741
|
+
"CODEMEM_DB",
|
|
2742
|
+
selected.db,
|
|
2743
|
+
"--db-path"
|
|
2744
|
+
],
|
|
2745
|
+
[
|
|
2746
|
+
"CODEMEM_KEYS_DIR",
|
|
2747
|
+
selected.keys,
|
|
2748
|
+
"--keys-dir"
|
|
2749
|
+
]
|
|
2750
|
+
]) {
|
|
2751
|
+
const selectedPath = normalizeEnrollmentPath(name, path);
|
|
2752
|
+
const override = normalizeEnrollmentPath(name, name === "CODEMEM_KEYS_DIR" ? process.env[name]?.trim() || void 0 : process.env[name]);
|
|
2753
|
+
if (selectedPath !== void 0 && override !== void 0 && resolve(selectedPath) !== resolve(override)) return reportUsageError(options, `Unset ${name} or make it match ${flag} before owner enrollment. Conflicting profile paths cannot be adopted safely.`);
|
|
2754
|
+
}
|
|
2755
|
+
return true;
|
|
2756
|
+
}
|
|
2757
|
+
function normalizeEnrollmentPath(name, path) {
|
|
2758
|
+
if (path === void 0) return void 0;
|
|
2759
|
+
if (name === "CODEMEM_KEYS_DIR") return path;
|
|
2760
|
+
const value = name === "CODEMEM_CONFIG" ? path.trim() : path;
|
|
2761
|
+
return value ? expandUserPath(value) : void 0;
|
|
2762
|
+
}
|
|
2763
|
+
function resolveEnrollmentProfile(options) {
|
|
2764
|
+
const configPath = resolveCodememConfigPath(options.config, "write").resolved.path;
|
|
2765
|
+
const config = readCodememConfigFileAtPath(configPath);
|
|
2766
|
+
if (config.sync_coordinator_url !== void 0 && !coordinatorOriginUsage(options, config.sync_coordinator_url)) return null;
|
|
2767
|
+
const coordinatorUrl = options.coordinator ?? readCoordinatorSyncConfig(config).syncCoordinatorUrl;
|
|
2768
|
+
if (!coordinatorUrl) {
|
|
2769
|
+
reportUsageError(options, "Set --coordinator to your coordinator HTTPS URL, or set sync_coordinator_url in the selected config.");
|
|
2770
|
+
return null;
|
|
2771
|
+
}
|
|
2772
|
+
if (!coordinatorOriginUsage(options, coordinatorUrl)) return null;
|
|
2773
|
+
const dbPath = resolveDbPath(resolveDbOpt(options));
|
|
2774
|
+
const keysDir = options.keysDir ?? (process.env.CODEMEM_KEYS_DIR?.trim() || void 0) ?? dirname(resolveKeyPaths()[0]);
|
|
2775
|
+
if (!pathEnvironmentUsage(options, {
|
|
2776
|
+
config: configPath,
|
|
2777
|
+
db: dbPath,
|
|
2778
|
+
keys: keysDir
|
|
2779
|
+
})) return null;
|
|
2780
|
+
return {
|
|
2781
|
+
configPath,
|
|
2782
|
+
coordinatorUrl,
|
|
2783
|
+
dbPath,
|
|
2784
|
+
keysDir
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
async function enroll(groupId, options) {
|
|
2788
|
+
if (!usage(options)) return;
|
|
2789
|
+
if (!groupUsage(options, groupId)) return;
|
|
2790
|
+
if (!pathEnvironmentUsage(options, {
|
|
2791
|
+
config: options.config,
|
|
2792
|
+
db: resolveDbOpt(options),
|
|
2793
|
+
keys: options.keysDir
|
|
2794
|
+
})) return;
|
|
2795
|
+
const controller = new AbortController();
|
|
2796
|
+
const stop = () => controller.abort();
|
|
2797
|
+
process.once("SIGINT", stop);
|
|
2798
|
+
process.once("SIGTERM", stop);
|
|
2799
|
+
try {
|
|
2800
|
+
if (!options.json) p.log.warn(CAPTURE_WARNING, { output: process.stderr });
|
|
2801
|
+
const profile = resolveEnrollmentProfile(options);
|
|
2802
|
+
if (!profile) return;
|
|
2803
|
+
showResult(await enrollOwnerProfile({
|
|
2804
|
+
...profile,
|
|
2805
|
+
groupId,
|
|
2806
|
+
stateDir: options.stateDir ?? join(dirname(profile.dbPath), "owner-enrollment"),
|
|
2807
|
+
mode: options.prepare ? "prepare" : "resume",
|
|
2808
|
+
...admissionOptions(options),
|
|
2809
|
+
capability: options.capability,
|
|
2810
|
+
reviewedDigest: options.reviewDigest,
|
|
2811
|
+
signal: controller.signal
|
|
2812
|
+
}), options);
|
|
2813
|
+
} catch (error) {
|
|
2814
|
+
const safe = error instanceof OwnerProfileEnrollmentError ? new OwnerProfileEnrollmentError(error.code) : new OwnerProfileEnrollmentError("review_required");
|
|
2815
|
+
const message = safe.message;
|
|
2816
|
+
if (options.json) emitJsonError(safe.code, message);
|
|
2817
|
+
else {
|
|
2818
|
+
p.log.error(message, { output: process.stderr });
|
|
2819
|
+
process.exitCode = 1;
|
|
2820
|
+
}
|
|
2821
|
+
} finally {
|
|
2822
|
+
process.removeListener("SIGINT", stop);
|
|
2823
|
+
process.removeListener("SIGTERM", stop);
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
function showResult(result, options) {
|
|
2827
|
+
if (options.json) {
|
|
2828
|
+
process.stdout.write(`${JSON.stringify({
|
|
2829
|
+
...result,
|
|
2830
|
+
warning: CAPTURE_WARNING
|
|
2831
|
+
})}\n`);
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
if (result.kind === "prepared") {
|
|
2835
|
+
p.log.message(`Current Identity: ${result.review.actorId}\nDevice: ${result.review.deviceId}\nPublic key: ${result.review.publicKey}\nCapture: ${result.review.captureId ?? "none"} (${result.review.captureAssociation})\nOwn local memories: ${result.review.localMemoryCount}\nForeign rows retained without new access: ${result.review.retainedForeignMemoryCount}\nReview digest: ${result.reviewDigest}`, { output: process.stderr });
|
|
2836
|
+
p.log.message(`Private request file for operator admission or upload during --resume --account-admission: ${result.requestFile}`, { output: process.stderr });
|
|
2837
|
+
p.log.warn("Only the current Identity/device assignment will change. Signing keys, memory authorship and history stay unchanged; no membership is inferred.", { output: process.stderr });
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2840
|
+
p.log.success(`Verified coordinator Identity: ${result.identityId}\nOwner binding: ${result.bindingId}`, { output: process.stderr });
|
|
2841
|
+
}
|
|
2842
|
+
function buildCoordinatorEnrollOwnerCommand() {
|
|
2843
|
+
const command = new Command("enroll-owner").configureHelp(helpStyle).description("Review and enroll this existing profile's device owner").argument("<group>", "group id").option("-u, --coordinator <url>", "coordinator HTTPS origin (defaults to config)").option("--keys-dir <path>", "existing signing-key directory").option("-s, --state-dir <path>", "private retained-request directory").option("--prepare", "save a reviewed request for operator admission").option("--resume", "resume the saved request or config-only adoption").option("--capability <token>", "admin-issued capability for this exact start").option("--account-admission", "use signed-in account admission with --resume only").option("-v, --review-digest <digest>", "explicitly approve the prepared profile review");
|
|
2844
|
+
addDbOption(command);
|
|
2845
|
+
addConfigOption(command);
|
|
2846
|
+
addJsonOption(command);
|
|
2847
|
+
command.action(enroll);
|
|
2848
|
+
return command;
|
|
2849
|
+
}
|
|
2850
|
+
//#endregion
|
|
2851
|
+
//#region src/commands/coordinator-grant-identity-group.ts
|
|
2852
|
+
function failure$3(options, message, exitCode = 1) {
|
|
2853
|
+
if (options.json) {
|
|
2854
|
+
emitJsonError("identity_group_grant_unavailable", message, exitCode);
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
p.log.error(message, { output: process.stderr });
|
|
2858
|
+
process.exitCode = exitCode;
|
|
2859
|
+
}
|
|
2860
|
+
function grantErrorMessage(error) {
|
|
2861
|
+
if (error instanceof RemoteCoordinatorRequestError) {
|
|
2862
|
+
if (error.status === 401 || error.status === 403) return "Coordinator refused the configured admin credential. Ask the operator to check it; do not pass credentials on the command line.";
|
|
2863
|
+
if (error.code === "invalid_grant_input") return "Coordinator rejected the reference. Use the stored controller attestation reference returned by device-owner review.";
|
|
2864
|
+
if (error.code === "grant_authority_unavailable") return "The stored review cannot authorize this grant. Ask the operator to check current enrollment, review revocation, and group status; do not edit the database.";
|
|
2865
|
+
if (error.status === 429 || error.code === "rate_limited") return "Coordinator rate limit reached. Wait before previewing and confirming again.";
|
|
2866
|
+
if (error.status === 404) return "This coordinator does not support the grant endpoint. Ask the operator to check its deployment.";
|
|
2867
|
+
}
|
|
2868
|
+
return "Transport grant could not be confirmed. Its outcome may be unknown; check coordinator setup and the stored review before retrying. No local profile was changed.";
|
|
2869
|
+
}
|
|
2870
|
+
function configuredTarget(options) {
|
|
2871
|
+
const config = options.config ? readCodememConfigFileAtPath(options.config) : readCodememConfigFile();
|
|
2872
|
+
const sync = readCoordinatorSyncConfig(config);
|
|
2873
|
+
return {
|
|
2874
|
+
remoteUrl: normalizeCoordinatorAccountLinkOrigin(sync.syncCoordinatorUrl),
|
|
2875
|
+
adminSecret: sync.syncCoordinatorAdminSecret,
|
|
2876
|
+
timeoutS: Math.max(1, sync.syncCoordinatorTimeoutS)
|
|
2877
|
+
};
|
|
2878
|
+
}
|
|
2879
|
+
async function confirmGrant(request, options) {
|
|
2880
|
+
const approved = await p.confirm({
|
|
2881
|
+
message: "Issue the reviewed Identity's transport enrollment grant?",
|
|
2882
|
+
initialValue: false,
|
|
2883
|
+
output: process.stderr
|
|
2884
|
+
});
|
|
2885
|
+
if (p.isCancel(approved) || approved !== true) {
|
|
2886
|
+
p.log.info("Nothing changed.", { output: process.stderr });
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
const current = configuredTarget(options);
|
|
2890
|
+
if (current.remoteUrl !== request.remoteUrl || current.adminSecret !== request.adminSecret) return failure$3(options, "Coordinator configuration changed. Nothing changed; preview again.");
|
|
2891
|
+
const result = await coordinatorGrantIdentityGroupAction(request);
|
|
2892
|
+
p.log.success(`Transport enrollment grant ${result.kind}: coordinator ${result.grant.coordinator_id}; Identity ${result.grant.identity_id}; group ${result.grant.group_id}. No Team/Project access or local profile changes.`, { output: process.stderr });
|
|
2893
|
+
}
|
|
2894
|
+
async function grantIdentityGroup(attestationId, options) {
|
|
2895
|
+
try {
|
|
2896
|
+
if (!isCoordinatorControllerAttestationReference(attestationId)) return failure$3(options, "Use a stored controller-v1: reference followed by exactly 64 lowercase hexadecimal characters.", 2);
|
|
2897
|
+
const target = configuredTarget(options);
|
|
2898
|
+
if (!target.remoteUrl) return failure$3(options, "Configure a coordinator HTTPS origin without credentials, path, query, or fragment (HTTP only on literal loopback). Nothing changed.", 2);
|
|
2899
|
+
if (!target.adminSecret) return failure$3(options, "Ask the coordinator operator to configure the existing admin credential. Nothing changed.");
|
|
2900
|
+
const preview = {
|
|
2901
|
+
state: "preview",
|
|
2902
|
+
coordinator_url: target.remoteUrl,
|
|
2903
|
+
attestation_id: attestationId,
|
|
2904
|
+
unchanged: true,
|
|
2905
|
+
eligibility_checked: false,
|
|
2906
|
+
message: "Explicit transport enrollment grant only, not Team/Project access. The server derives Identity and group from the stored review. No Google flow or local profile changes. Run in a terminal without --json to confirm."
|
|
2907
|
+
};
|
|
2908
|
+
if (options.json) {
|
|
2909
|
+
process.stdout.write(`${JSON.stringify(preview)}\n`);
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
p.log.info(`Coordinator: ${target.remoteUrl}\nController attestation: ${attestationId}\n${preview.message}\nThis is a local request preview, not a check of server eligibility.`, { output: process.stderr });
|
|
2913
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) return;
|
|
2914
|
+
await confirmGrant({
|
|
2915
|
+
...target,
|
|
2916
|
+
remoteUrl: target.remoteUrl,
|
|
2917
|
+
attestationId
|
|
2918
|
+
}, options);
|
|
2919
|
+
} catch (error) {
|
|
2920
|
+
failure$3(options, grantErrorMessage(error));
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
function buildCoordinatorGrantIdentityGroupCommand() {
|
|
2924
|
+
const command = new Command("grant-identity-group").configureHelp(helpStyle).description("Preview and confirm an explicit transport enrollment grant").argument("<attestation-id>", "stored controller attestation reference");
|
|
2925
|
+
addConfigOption(command);
|
|
2926
|
+
addDbOption(command);
|
|
2927
|
+
addJsonOption(command);
|
|
2928
|
+
command.action(grantIdentityGroup);
|
|
2929
|
+
return command;
|
|
2930
|
+
}
|
|
2931
|
+
//#endregion
|
|
2932
|
+
//#region src/commands/coordinator-link-account.ts
|
|
2933
|
+
function reportLinkFailure(error, coordinatorOrigin) {
|
|
2934
|
+
const message = error instanceof CoordinatorAccountLinkError ? error.message : "Account linking could not finish. Check your existing device setup and coordinator configuration, then try again.";
|
|
2935
|
+
p.log.error(message, { output: process.stderr });
|
|
2936
|
+
if (!(error instanceof CoordinatorAccountLinkError)) return;
|
|
2937
|
+
if (error.code !== "link_conflict") return;
|
|
2938
|
+
let guidance = "Replacing or removing coordinator account links is not supported. ";
|
|
2939
|
+
if (coordinatorOrigin) {
|
|
2940
|
+
const signInUrl = new URL("/auth/sign-in", coordinatorOrigin).href;
|
|
2941
|
+
guidance += `If this Google account already belongs to the intended coordinator Identity, sign in at ${signInUrl}. Otherwise, ask your coordinator operator.`;
|
|
2942
|
+
} else guidance += "Ask your coordinator operator.";
|
|
2943
|
+
p.log.message(guidance, { output: process.stderr });
|
|
2944
|
+
}
|
|
2945
|
+
function showPrivateBrowserLink(privateUrl) {
|
|
2946
|
+
p.log.warn("Keep this browser link private. Open it to review account linking, then return to the coordinator tab to finish.", { output: process.stderr });
|
|
2947
|
+
p.log.message(privateUrl, { output: process.stderr });
|
|
2948
|
+
}
|
|
2949
|
+
function requireInteractive(options) {
|
|
2950
|
+
if (options.json) {
|
|
2951
|
+
emitJsonError("interactive_flow_only", "Account linking is interactive. Run without --json and keep the browser link private.", 2);
|
|
2952
|
+
return false;
|
|
2953
|
+
}
|
|
2954
|
+
if (!process.stderr.isTTY) {
|
|
2955
|
+
p.log.error("Account linking requires a terminal. Run this command interactively and keep the browser link private.", { output: process.stderr });
|
|
2956
|
+
process.exitCode = 2;
|
|
2957
|
+
return false;
|
|
2958
|
+
}
|
|
2959
|
+
return true;
|
|
2960
|
+
}
|
|
2961
|
+
function readLinkConfiguration(options) {
|
|
2962
|
+
const config = options.config ? readCodememConfigFileAtPath(options.config) : readCodememConfigFile();
|
|
2963
|
+
const coordinatorUrl = normalizeCoordinatorAccountLinkOrigin(options.coordinator ?? readCoordinatorSyncConfig(config).syncCoordinatorUrl);
|
|
2964
|
+
if (!coordinatorUrl || options.loopbackHost !== "127.0.0.1" && options.loopbackHost !== "::1") return null;
|
|
2965
|
+
return {
|
|
2966
|
+
config,
|
|
2967
|
+
coordinatorUrl,
|
|
2968
|
+
loopbackHost: options.loopbackHost
|
|
2969
|
+
};
|
|
2970
|
+
}
|
|
2971
|
+
function readLinkProfile(options, selected) {
|
|
2972
|
+
try {
|
|
2973
|
+
const dbPath = resolveDbPath(resolveDbOpt(options));
|
|
2974
|
+
const before = statSync(dbPath, { bigint: true });
|
|
2975
|
+
const device = readAccountLinkDevice(dbPath);
|
|
2976
|
+
const after = statSync(dbPath, { bigint: true });
|
|
2977
|
+
const override = process.env.CODEMEM_DEVICE_ID?.trim() || void 0;
|
|
2978
|
+
if (!before.isFile() || before.dev !== after.dev || before.ino !== after.ino || override !== void 0 && override !== device.deviceId) throw new CoordinatorAccountLinkError("device_unavailable");
|
|
2979
|
+
let actor = selected.config.actor_id;
|
|
2980
|
+
if (Object.hasOwn(process.env, "CODEMEM_ACTOR_ID")) actor = process.env.CODEMEM_ACTOR_ID;
|
|
2981
|
+
const identityId = (typeof actor === "string" ? actor.trim() : "") || `local:${device.deviceId}`;
|
|
2982
|
+
return {
|
|
2983
|
+
dbPath,
|
|
2984
|
+
keysDir: process.env.CODEMEM_KEYS_DIR?.trim() || void 0,
|
|
2985
|
+
coordinatorUrl: selected.coordinatorUrl,
|
|
2986
|
+
expectedIdentityId: identityId,
|
|
2987
|
+
deviceId: device.deviceId,
|
|
2988
|
+
fingerprint: device.fingerprint,
|
|
2989
|
+
fileIdentity: {
|
|
2990
|
+
dev: after.dev.toString(),
|
|
2991
|
+
ino: after.ino.toString()
|
|
2992
|
+
}
|
|
2993
|
+
};
|
|
2994
|
+
} catch {
|
|
2995
|
+
throw new CoordinatorAccountLinkError("device_unavailable");
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
function profileMatches(options, snapshot) {
|
|
2999
|
+
const selected = readLinkConfiguration(options);
|
|
3000
|
+
return selected !== null && JSON.stringify(readLinkProfile(options, selected)) === snapshot;
|
|
3001
|
+
}
|
|
3002
|
+
function eligibleStatus(remote, identityId) {
|
|
3003
|
+
if (remote.kind !== "status") {
|
|
3004
|
+
if (remote.kind === "unavailable" && remote.code === "coordinator_auth_schema_upgrade_required") throw new CoordinatorAccountLinkError("schema_upgrade_required");
|
|
3005
|
+
throw new CoordinatorAccountLinkError("invalid_response");
|
|
3006
|
+
}
|
|
3007
|
+
const status = remote.status;
|
|
3008
|
+
if (status.state === "conflict" || status.state === "revoked") throw new CoordinatorAccountLinkError("link_conflict");
|
|
3009
|
+
if (status.state === "review_required") throw new CoordinatorAccountLinkError("review_required");
|
|
3010
|
+
if (status.identityId !== identityId) throw new CoordinatorAccountLinkError("identity_mismatch");
|
|
3011
|
+
return status;
|
|
3012
|
+
}
|
|
3013
|
+
async function linkAccount(group, options) {
|
|
3014
|
+
if (!requireInteractive(options)) return;
|
|
3015
|
+
const controller = new AbortController();
|
|
3016
|
+
const stop = () => controller.abort();
|
|
3017
|
+
process.once("SIGINT", stop);
|
|
3018
|
+
process.once("SIGTERM", stop);
|
|
3019
|
+
let coordinatorOrigin = null;
|
|
3020
|
+
try {
|
|
3021
|
+
const selected = readLinkConfiguration(options);
|
|
3022
|
+
if (!selected) {
|
|
3023
|
+
p.log.error("Set --coordinator to your coordinator HTTPS origin and use --loopback-host 127.0.0.1 or ::1.", { output: process.stderr });
|
|
3024
|
+
process.exitCode = 2;
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
const { coordinatorUrl } = selected;
|
|
3028
|
+
coordinatorOrigin = coordinatorUrl;
|
|
3029
|
+
const profile = readLinkProfile(options, selected);
|
|
3030
|
+
const status = eligibleStatus(await readCoordinatorAccountLinkStatus({
|
|
3031
|
+
...profile,
|
|
3032
|
+
coordinatorUrl,
|
|
3033
|
+
identityId: profile.expectedIdentityId,
|
|
3034
|
+
groupId: group,
|
|
3035
|
+
signal: controller.signal
|
|
3036
|
+
}), profile.expectedIdentityId);
|
|
3037
|
+
if (status.state === "linked") {
|
|
3038
|
+
p.log.success("This Identity is already linked. No account-link request was started.", { output: process.stderr });
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
const snapshot = JSON.stringify(profile);
|
|
3042
|
+
const result = await linkCoordinatorAccount({
|
|
3043
|
+
...profile,
|
|
3044
|
+
groupId: group,
|
|
3045
|
+
coordinatorUrl,
|
|
3046
|
+
expectedAuthority: status.authority,
|
|
3047
|
+
expectedCoordinatorId: status.coordinatorId,
|
|
3048
|
+
assertCurrentContext: () => profileMatches(options, snapshot),
|
|
3049
|
+
loopbackHost: selected.loopbackHost,
|
|
3050
|
+
signal: controller.signal,
|
|
3051
|
+
onBrowserStart: showPrivateBrowserLink
|
|
3052
|
+
});
|
|
3053
|
+
p.log.success(`Coordinator account linked to coordinator Identity ${result.identityId}. Your local Identity has not changed.`, { output: process.stderr });
|
|
3054
|
+
} catch (error) {
|
|
3055
|
+
reportLinkFailure(error, coordinatorOrigin);
|
|
3056
|
+
process.exitCode = 1;
|
|
3057
|
+
} finally {
|
|
3058
|
+
process.removeListener("SIGINT", stop);
|
|
3059
|
+
process.removeListener("SIGTERM", stop);
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
function buildCoordinatorLinkAccountCommand() {
|
|
3063
|
+
const command = new Command("link-account").configureHelp(helpStyle).description("Link a coordinator account to your existing Identity").argument("<group>", "group id").option("-u, --coordinator <url>", "coordinator HTTPS origin (defaults to saved config)").option("-l, --loopback-host <host>", "literal loopback host", "127.0.0.1");
|
|
3064
|
+
addDbOption(command);
|
|
3065
|
+
addConfigOption(command);
|
|
3066
|
+
addJsonOption(command);
|
|
3067
|
+
command.action(linkAccount);
|
|
3068
|
+
return command;
|
|
3069
|
+
}
|
|
3070
|
+
//#endregion
|
|
3071
|
+
//#region src/commands/coordinator-qualify-owner.ts
|
|
3072
|
+
var isAttestation = (value) => typeof value === "string" && /^controller-v1:[a-f0-9]{64}$/u.test(value);
|
|
3073
|
+
var isHash$1 = (value) => typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
|
3074
|
+
function isRef(value) {
|
|
3075
|
+
return typeof value === "string" && value.length > 0 && value.length <= 256 && value.trim() === value && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(value);
|
|
3076
|
+
}
|
|
3077
|
+
function setup$1(options) {
|
|
3078
|
+
const config = options.config ? readCodememConfigFileAtPath(options.config) : readCodememConfigFile();
|
|
3079
|
+
const sync = readCoordinatorSyncConfig(config);
|
|
3080
|
+
const remoteUrl = normalizeCoordinatorAccountLinkOrigin(sync.syncCoordinatorUrl);
|
|
3081
|
+
if (!remoteUrl || !sync.syncCoordinatorAdminSecret) throw new Error("configuration_unavailable");
|
|
3082
|
+
return {
|
|
3083
|
+
remoteUrl,
|
|
3084
|
+
adminSecret: sync.syncCoordinatorAdminSecret,
|
|
3085
|
+
timeoutS: Math.max(1, sync.syncCoordinatorTimeoutS)
|
|
3086
|
+
};
|
|
3087
|
+
}
|
|
3088
|
+
function parsePreview$1(reply, attestationId) {
|
|
3089
|
+
if (reply.state !== "ready" || reply.attestation_id !== attestationId || !isHash$1(reply.evidence_digest) || !isHash$1(reply.fingerprint) || ![
|
|
3090
|
+
"coordinator_id",
|
|
3091
|
+
"review_receipt_id",
|
|
3092
|
+
"link_id",
|
|
3093
|
+
"claim_id",
|
|
3094
|
+
"identity_id",
|
|
3095
|
+
"group_id",
|
|
3096
|
+
"device_id"
|
|
3097
|
+
].every((key) => isRef(reply[key]))) throw new Error("invalid_preview");
|
|
3098
|
+
return {
|
|
3099
|
+
state: "ready",
|
|
3100
|
+
attestation_id: attestationId,
|
|
3101
|
+
evidence_digest: reply.evidence_digest,
|
|
3102
|
+
coordinator_id: reply.coordinator_id,
|
|
3103
|
+
review_receipt_id: reply.review_receipt_id,
|
|
3104
|
+
link_id: reply.link_id,
|
|
3105
|
+
claim_id: reply.claim_id,
|
|
3106
|
+
identity_id: reply.identity_id,
|
|
3107
|
+
group_id: reply.group_id,
|
|
3108
|
+
device_id: reply.device_id,
|
|
3109
|
+
fingerprint: reply.fingerprint
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
var QUALIFICATION_DENIAL_CODES = [
|
|
3113
|
+
"invalid_input",
|
|
3114
|
+
"preview_changed",
|
|
3115
|
+
"qualification_conflict",
|
|
3116
|
+
"unavailable"
|
|
3117
|
+
];
|
|
3118
|
+
function failure$2(options, error, message, exitCode = 1) {
|
|
3119
|
+
if (options.json) return emitJsonError(error, message, exitCode);
|
|
3120
|
+
p.log.error(message, { output: process.stderr });
|
|
3121
|
+
process.exitCode = exitCode;
|
|
3122
|
+
}
|
|
3123
|
+
function sameConfiguration$1(options, request) {
|
|
3124
|
+
try {
|
|
3125
|
+
const current = setup$1(options);
|
|
3126
|
+
return current.remoteUrl === request.remoteUrl && current.adminSecret === request.adminSecret;
|
|
3127
|
+
} catch {
|
|
3128
|
+
return false;
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
async function confirmQualification(options, request, preview, stage) {
|
|
3132
|
+
const approved = await p.confirm({
|
|
3133
|
+
message: "Allow this linked account to enroll new devices under this Identity?",
|
|
3134
|
+
initialValue: false,
|
|
3135
|
+
output: process.stderr
|
|
3136
|
+
});
|
|
3137
|
+
if (p.isCancel(approved) || !approved) {
|
|
3138
|
+
p.log.info("Nothing changed.", { output: process.stderr });
|
|
3139
|
+
return;
|
|
3140
|
+
}
|
|
3141
|
+
if (!sameConfiguration$1(options, request)) return failure$2(options, "qualification_configuration_changed", "Operator configuration changed. Preview again before confirming.");
|
|
3142
|
+
stage.onDispatch();
|
|
3143
|
+
const result = await coordinatorQualifyOwnerAction({
|
|
3144
|
+
...request,
|
|
3145
|
+
confirmEvidenceDigest: preview.evidence_digest
|
|
3146
|
+
});
|
|
3147
|
+
if (result.state !== "created" && result.state !== "existing" || !isRef(result.qualification_id) || result.attestation_id !== request.attestationId || result.coordinator_id !== preview.coordinator_id || result.evidence_digest !== preview.evidence_digest) throw new Error("invalid_commit_response");
|
|
3148
|
+
p.log.success("Linked account qualified for device enrollment. Team and Project access are unchanged.", { output: process.stderr });
|
|
3149
|
+
}
|
|
3150
|
+
async function qualifyOwner(attestationId, options) {
|
|
3151
|
+
let writeAttempted = false;
|
|
3152
|
+
try {
|
|
3153
|
+
if (!isAttestation(attestationId)) return failure$2(options, "qualification_invalid_input", "Use the exact controller-v1 reference from a real device-owner review.", 2);
|
|
3154
|
+
const request = {
|
|
3155
|
+
...setup$1(options),
|
|
3156
|
+
attestationId
|
|
3157
|
+
};
|
|
3158
|
+
const preview = parsePreview$1(await coordinatorQualifyOwnerAction(request), attestationId);
|
|
3159
|
+
if (options.json) {
|
|
3160
|
+
process.stdout.write(`${JSON.stringify({
|
|
3161
|
+
preview,
|
|
3162
|
+
network_request: true,
|
|
3163
|
+
unchanged: true
|
|
3164
|
+
})}\n`);
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
p.log.info(`Network preview from ${preview.coordinator_id}\nIdentity: ${preview.identity_id}\nLinked account reference: ${preview.link_id}\nDevice: ${preview.device_id}; fingerprint: ${preview.fingerprint}\nReview: ${preview.attestation_id}\nReceipt: ${preview.review_receipt_id}\nClaim: ${preview.claim_id}; group: ${preview.group_id}`, { output: process.stderr });
|
|
3168
|
+
p.log.warn("Independently verify the real operator review. Qualification permits new devices under this Identity; an independently trusted transport-group grant is still separate. This does not create a Team, issue a grant, or rewrite a device claim.", { output: process.stderr });
|
|
3169
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
3170
|
+
p.log.info("Preview only; no qualification issued. Use a terminal without --json to confirm.", { output: process.stderr });
|
|
3171
|
+
return;
|
|
3172
|
+
}
|
|
3173
|
+
await confirmQualification(options, request, preview, { onDispatch: () => {
|
|
3174
|
+
writeAttempted = true;
|
|
3175
|
+
} });
|
|
3176
|
+
} catch (error) {
|
|
3177
|
+
if (error instanceof RemoteCoordinatorRequestError && error.status === 409 && QUALIFICATION_DENIAL_CODES.includes(error.code)) return failure$2(options, "qualification_rejected", "Qualification denied: evidence is invalid, changed, was revoked, conflicts, or is unavailable. Preview again; no automatic retry.");
|
|
3178
|
+
let message = "Qualification server unavailable or preview invalid. Ask the coordinator operator to check the configured Worker's browser authentication and owner-enrollment settings, required migrations 0035 and 0036, and the saved trusted HTTPS coordinator origin and admin credential.";
|
|
3179
|
+
if (writeAttempted) message = "Qualification outcome is unknown. It may have committed; do not assume rollback or retry automatically. Ask the coordinator operator to inspect the outcome.";
|
|
3180
|
+
failure$2(options, writeAttempted ? "qualification_outcome_unknown" : "qualification_unavailable", message);
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
function buildCoordinatorQualifyOwnerCommand() {
|
|
3184
|
+
const command = new Command("qualify-owner").configureHelp(helpStyle).description("Preview and confirm linked-account device enrollment permission").argument("<attestation-id>", "controller-v1 reference from a real operator review");
|
|
3185
|
+
addConfigOption(command);
|
|
3186
|
+
addJsonOption(command);
|
|
3187
|
+
command.action(qualifyOwner);
|
|
3188
|
+
return command;
|
|
3189
|
+
}
|
|
3190
|
+
//#endregion
|
|
3191
|
+
//#region src/commands/coordinator-review-device-owner.ts
|
|
3192
|
+
var REVIEW_REASONS = [
|
|
3193
|
+
"group_unavailable",
|
|
3194
|
+
"enrollment_unavailable",
|
|
3195
|
+
"key_mismatch",
|
|
3196
|
+
"enrollment_identity_mismatch",
|
|
3197
|
+
"invite_identity_mismatch",
|
|
3198
|
+
"invite_evidence_invalid"
|
|
3199
|
+
];
|
|
3200
|
+
function validId$1(value) {
|
|
3201
|
+
return typeof value === "string" && value.length > 0 && value.length <= 256 && value.trim() === value && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(value);
|
|
3202
|
+
}
|
|
3203
|
+
function isHash(value) {
|
|
3204
|
+
return typeof value === "string" && value.length === 64 && /^[a-f0-9]{64}$/.test(value);
|
|
3205
|
+
}
|
|
3206
|
+
function reviewErrorMessage(error) {
|
|
3207
|
+
if (error instanceof RemoteCoordinatorRequestError) {
|
|
3208
|
+
if (error.code === "review_stale") return "Evidence changed since the preview. Nothing changed; run the review again.";
|
|
3209
|
+
if (error.code === "already_reviewed_or_needs_review") return "This device already has a reviewed owner, or needs a separate review. Nothing changed.";
|
|
3210
|
+
if (error.code === "needs_review") return "Coordinator ownership evidence needs review. Nothing changed; ask the coordinator operator.";
|
|
3211
|
+
}
|
|
3212
|
+
return "Device owner review could not finish. Check the existing device, admin credential, and coordinator setup; preview again before retrying.";
|
|
3213
|
+
}
|
|
3214
|
+
function failure$1(options, message, exitCode = 1) {
|
|
3215
|
+
if (options.json) {
|
|
3216
|
+
emitJsonError("owner_review_unavailable", message, exitCode);
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
3219
|
+
p.log.error(message, { output: process.stderr });
|
|
3220
|
+
process.exitCode = exitCode;
|
|
3221
|
+
}
|
|
3222
|
+
function localOptions(options) {
|
|
3223
|
+
const config = options.config ? readCodememConfigFileAtPath(options.config) : readCodememConfigFile();
|
|
3224
|
+
const sync = readCoordinatorSyncConfig(config);
|
|
3225
|
+
let actorId;
|
|
3226
|
+
let identitySource = "config";
|
|
3227
|
+
if (Object.hasOwn(process.env, "CODEMEM_ACTOR_ID")) {
|
|
3228
|
+
actorId = process.env.CODEMEM_ACTOR_ID?.trim();
|
|
3229
|
+
identitySource = "env";
|
|
3230
|
+
} else if (typeof config.actor_id === "string") actorId = config.actor_id.trim();
|
|
3231
|
+
return {
|
|
3232
|
+
local: {
|
|
3233
|
+
dbPath: resolveDbPath(resolveDbOpt(options)),
|
|
3234
|
+
actorId,
|
|
3235
|
+
identitySource,
|
|
3236
|
+
deviceIdOverride: process.env.CODEMEM_DEVICE_ID?.trim()
|
|
3237
|
+
},
|
|
3238
|
+
remoteUrl: normalizeCoordinatorAccountLinkOrigin(options.coordinator ?? sync.syncCoordinatorUrl),
|
|
3239
|
+
adminSecret: sync.syncCoordinatorAdminSecret,
|
|
3240
|
+
timeoutS: Math.max(1, sync.syncCoordinatorTimeoutS)
|
|
3241
|
+
};
|
|
3242
|
+
}
|
|
3243
|
+
function displayLocal(evidence) {
|
|
3244
|
+
const device = evidence.device;
|
|
3245
|
+
return {
|
|
3246
|
+
...evidence,
|
|
3247
|
+
device: device ? {
|
|
3248
|
+
deviceId: device.deviceId,
|
|
3249
|
+
fingerprint: device.fingerprint,
|
|
3250
|
+
label: device.label
|
|
3251
|
+
} : null
|
|
3252
|
+
};
|
|
3253
|
+
}
|
|
3254
|
+
function showLocal(evidence) {
|
|
3255
|
+
const count = (value) => value === null ? "unknown" : String(value);
|
|
3256
|
+
const device = evidence.device;
|
|
3257
|
+
const identity = evidence.identity;
|
|
3258
|
+
p.log.message([
|
|
3259
|
+
`Device: ${device?.label ?? device?.deviceId ?? "unavailable"}`,
|
|
3260
|
+
`Device ID: ${device?.deviceId ?? "unknown"}; fingerprint: ${device?.fingerprint ?? "unknown"}`,
|
|
3261
|
+
`Identity: ${identity?.label ?? identity?.identityId ?? "unavailable"} (${identity?.source ?? "unknown"})`,
|
|
3262
|
+
`Identity ID: ${identity?.identityId ?? "unknown"}`,
|
|
3263
|
+
`Memory authors: current ${count(evidence.memoryCounts.current)}, other ${count(evidence.memoryCounts.others)}, unknown ${count(evidence.memoryCounts.unknown)} — unchanged`,
|
|
3264
|
+
`Active Teams: ${count(evidence.teamCount)}; direct Projects: ${count(evidence.projectCount)} — unchanged`,
|
|
3265
|
+
"This review does not move memories or change access. Google account linking is a separate step."
|
|
3266
|
+
].join("\n"), { output: process.stderr });
|
|
3267
|
+
}
|
|
3268
|
+
function validInviteCount(reply, invites) {
|
|
3269
|
+
return invites.length <= 10 && Number.isSafeInteger(reply.reviewed_invite_count) && reply.reviewed_invite_count >= invites.length;
|
|
3270
|
+
}
|
|
3271
|
+
function validInviteRef(invite) {
|
|
3272
|
+
return !!invite && validId$1(invite.invite_id) && (invite.kind === "team_member" || invite.kind === "add_device");
|
|
3273
|
+
}
|
|
3274
|
+
function parsePreview(payload, request) {
|
|
3275
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("invalid_preview");
|
|
3276
|
+
const reply = payload;
|
|
3277
|
+
const enrollment = reply.enrollment;
|
|
3278
|
+
const invites = reply.reviewed_invites;
|
|
3279
|
+
if (reply.state !== "ready" && reply.state !== "needs_review" || !validId$1(reply.coordinator_id) || !Array.isArray(reply.reasons) || !reply.reasons.every((reason) => typeof reason === "string" && REVIEW_REASONS.includes(reason)) || !Array.isArray(invites) || !validInviteCount(reply, invites) || !invites.every(validInviteRef)) throw new Error("invalid_preview");
|
|
3280
|
+
if (enrollment !== null && (!enrollment || enrollment.device_id !== request.deviceId || !isHash(enrollment.fingerprint) || !["none", "matches"].includes(enrollment.identity_label))) throw new Error("invalid_preview");
|
|
3281
|
+
if (reply.state === "ready" && (!enrollment || enrollment.device_id !== request.deviceId || enrollment.fingerprint !== request.fingerprint || !["none", "matches"].includes(enrollment.identity_label) || !isHash(reply.evidence_digest) || reply.reasons.length)) throw new Error("invalid_preview");
|
|
3282
|
+
return {
|
|
3283
|
+
state: reply.state,
|
|
3284
|
+
coordinator_id: reply.coordinator_id,
|
|
3285
|
+
reasons: reply.reasons,
|
|
3286
|
+
evidence_digest: reply.state === "ready" ? reply.evidence_digest : void 0,
|
|
3287
|
+
enrollment: enrollment ? {
|
|
3288
|
+
device_id: enrollment.device_id,
|
|
3289
|
+
fingerprint: enrollment.fingerprint,
|
|
3290
|
+
display_name: null,
|
|
3291
|
+
identity_label: enrollment.identity_label
|
|
3292
|
+
} : null,
|
|
3293
|
+
reviewed_invites: invites.map((invite) => ({
|
|
3294
|
+
invite_id: invite.invite_id,
|
|
3295
|
+
kind: invite.kind
|
|
3296
|
+
})),
|
|
3297
|
+
reviewed_invite_count: reply.reviewed_invite_count
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
function sameLocalOwner(before, after) {
|
|
3301
|
+
return after.state === "ready" && before.ownershipRecords.actorPresent === after.ownershipRecords.actorPresent && before.ownershipRecords.deviceAssignmentPresent === after.ownershipRecords.deviceAssignmentPresent && before.device?.deviceId === after.device?.deviceId && before.device?.publicKey === after.device?.publicKey && before.device?.fingerprint === after.device?.fingerprint && before.identity?.identityId === after.identity?.identityId && before.identity?.source === after.identity?.source;
|
|
3302
|
+
}
|
|
3303
|
+
async function confirmReview(group, options, evidence, request, preview) {
|
|
3304
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) return failure$1(options, "Preview only. Run in a terminal without --json to confirm this review.", 2);
|
|
3305
|
+
const approved = await p.confirm({
|
|
3306
|
+
message: "Review this device owner?",
|
|
3307
|
+
initialValue: false,
|
|
3308
|
+
output: process.stderr
|
|
3309
|
+
});
|
|
3310
|
+
if (p.isCancel(approved) || !approved) {
|
|
3311
|
+
p.log.info("Nothing changed.", { output: process.stderr });
|
|
3312
|
+
return;
|
|
3313
|
+
}
|
|
3314
|
+
const current = localOptions(options);
|
|
3315
|
+
if (current.remoteUrl !== request.remoteUrl || !sameLocalOwner(evidence, readCoordinatorOwnerReviewLocalEvidence(current.local))) return failure$1(options, "Your device or Identity changed. Nothing changed on the coordinator; preview again.");
|
|
3316
|
+
const result = await coordinatorAuthControllerReviewAction({
|
|
3317
|
+
...request,
|
|
3318
|
+
confirmEvidenceDigest: preview.evidence_digest
|
|
3319
|
+
});
|
|
3320
|
+
if (result.state !== "created" && result.state !== "existing" || typeof result.attestation_id !== "string" || result.attestation_id.length !== 78 || !/^controller-v1:[a-f0-9]{64}$/.test(result.attestation_id) || result.coordinator_id !== preview.coordinator_id || result.group_id !== group || result.device_id !== request.deviceId || result.identity_id !== request.identityId || result.fingerprint !== request.fingerprint || typeof result.created_at !== "string") throw new Error("invalid_commit_response");
|
|
3321
|
+
p.log.success("Device owner reviewed. Memories and access are unchanged.", { output: process.stderr });
|
|
3322
|
+
p.log.info(`Controller attestation reference: ${result.attestation_id}\nKeep this noncredential reference for a separate, explicit operator-issued transport group grant. This review does not issue that grant or grant Team/Project access.`, { output: process.stderr });
|
|
3323
|
+
if (showTargetedLinkFollowUp(options)) return;
|
|
3324
|
+
showLinkFollowUp(group);
|
|
3325
|
+
}
|
|
3326
|
+
function showLinkFollowUp(group) {
|
|
3327
|
+
if (group.includes("\\")) {
|
|
3328
|
+
p.log.info(`Next, run codemem coordinator link-account with group ID ${JSON.stringify(group)}. Quote the ID for your shell.`, { output: process.stderr });
|
|
3329
|
+
return;
|
|
3330
|
+
}
|
|
3331
|
+
const quotedGroup = `'${group.replaceAll("'", "'\\''")}'`;
|
|
3332
|
+
p.log.info(`Next, link your Google account separately: codemem coordinator link-account ${quotedGroup}`, { output: process.stderr });
|
|
3333
|
+
}
|
|
3334
|
+
function showTargetedLinkFollowUp(options) {
|
|
3335
|
+
if (!options.coordinator && !options.config && !resolveDbOpt(options)) return false;
|
|
3336
|
+
p.log.info("Next, link your Google account separately with codemem coordinator link-account for this group. Reuse the same --coordinator, --config, and --db-path options from this review.", { output: process.stderr });
|
|
3337
|
+
return true;
|
|
3338
|
+
}
|
|
3339
|
+
function showLocalStop(evidence, options) {
|
|
3340
|
+
if (options.json) {
|
|
3341
|
+
process.stdout.write(`${JSON.stringify({
|
|
3342
|
+
...displayLocal(evidence),
|
|
3343
|
+
unchanged: true,
|
|
3344
|
+
error: "needs_review",
|
|
3345
|
+
message: "Local device ownership needs review. Nothing changed."
|
|
3346
|
+
})}\n`);
|
|
3347
|
+
return;
|
|
3348
|
+
}
|
|
3349
|
+
p.log.warn(`Review stopped: ${evidence.reasons.join(", ")}. Nothing changed.`, { output: process.stderr });
|
|
3350
|
+
}
|
|
3351
|
+
function emitReviewPreview(evidence, preview) {
|
|
3352
|
+
let stopped = {};
|
|
3353
|
+
if (preview.state !== "ready") {
|
|
3354
|
+
stopped = {
|
|
3355
|
+
error: "needs_review",
|
|
3356
|
+
message: "Coordinator device ownership needs review. Nothing changed."
|
|
3357
|
+
};
|
|
3358
|
+
process.exitCode = 1;
|
|
3359
|
+
}
|
|
3360
|
+
process.stdout.write(`${JSON.stringify({
|
|
3361
|
+
local: displayLocal(evidence),
|
|
3362
|
+
review: preview,
|
|
3363
|
+
unchanged: true,
|
|
3364
|
+
...stopped
|
|
3365
|
+
})}\n`);
|
|
3366
|
+
}
|
|
3367
|
+
async function reviewDeviceOwner(group, options) {
|
|
3368
|
+
try {
|
|
3369
|
+
if (!validId$1(group)) return failure$1(options, "Use a valid coordinator group ID.", 2);
|
|
3370
|
+
const setup = localOptions(options);
|
|
3371
|
+
const evidence = readCoordinatorOwnerReviewLocalEvidence(setup.local);
|
|
3372
|
+
if (!options.json) showLocal(evidence);
|
|
3373
|
+
if (evidence.state !== "ready" || !evidence.device || !evidence.identity) {
|
|
3374
|
+
showLocalStop(evidence, options);
|
|
3375
|
+
process.exitCode = 1;
|
|
3376
|
+
return;
|
|
3377
|
+
}
|
|
3378
|
+
if (!setup.adminSecret) return failure$1(options, "Ask the coordinator operator to configure the existing admin credential. Nothing changed.");
|
|
3379
|
+
if (!setup.remoteUrl) return failure$1(options, "Set --coordinator to a coordinator HTTPS origin (HTTP is allowed only on literal loopback).", 2);
|
|
3380
|
+
const request = {
|
|
3381
|
+
remoteUrl: setup.remoteUrl,
|
|
3382
|
+
adminSecret: setup.adminSecret,
|
|
3383
|
+
timeoutS: setup.timeoutS,
|
|
3384
|
+
groupId: group,
|
|
3385
|
+
deviceId: evidence.device.deviceId,
|
|
3386
|
+
identityId: evidence.identity.identityId,
|
|
3387
|
+
fingerprint: evidence.device.fingerprint
|
|
3388
|
+
};
|
|
3389
|
+
const preview = parsePreview(await coordinatorAuthControllerReviewAction(request), request);
|
|
3390
|
+
if (options.json) {
|
|
3391
|
+
emitReviewPreview(evidence, preview);
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
if (preview.state !== "ready") return failure$1(options, `Coordinator review stopped: ${preview.reasons.join(", ")}. Nothing changed.`);
|
|
3395
|
+
p.log.info(`Coordinator: ${preview.coordinator_id}; reviewed invitations: ${preview.reviewed_invite_count}`, { output: process.stderr });
|
|
3396
|
+
await confirmReview(group, options, evidence, request, preview);
|
|
3397
|
+
} catch (error) {
|
|
3398
|
+
failure$1(options, reviewErrorMessage(error));
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
function buildCoordinatorReviewDeviceOwnerCommand() {
|
|
3402
|
+
const command = new Command("review-device-owner").configureHelp(helpStyle).description("Preview and confirm the owner of your existing device").argument("<group>", "group id").option("-u, --coordinator <url>", "coordinator HTTPS origin (defaults to saved config)");
|
|
3403
|
+
addDbOption(command);
|
|
3404
|
+
addConfigOption(command);
|
|
3405
|
+
addJsonOption(command);
|
|
3406
|
+
command.action(reviewDeviceOwner);
|
|
3407
|
+
return command;
|
|
3408
|
+
}
|
|
3409
|
+
//#endregion
|
|
3410
|
+
//#region src/commands/coordinator-revoke-device.ts
|
|
3411
|
+
function validId(value) {
|
|
3412
|
+
return value.length > 0 && value.length <= 256 && value.trim() === value && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(value);
|
|
3413
|
+
}
|
|
3414
|
+
function setup(options) {
|
|
3415
|
+
const config = options.config ? readCodememConfigFileAtPath(options.config) : readCodememConfigFile();
|
|
3416
|
+
const remoteUrl = normalizeCoordinatorAccountLinkOrigin(config.sync_coordinator_url);
|
|
3417
|
+
const adminSecret = config.sync_coordinator_admin_secret;
|
|
3418
|
+
const timeoutS = config.sync_coordinator_timeout_s ?? 3;
|
|
3419
|
+
if (!remoteUrl || typeof adminSecret !== "string" || !adminSecret.trim() || /[\r\n\0]/u.test(adminSecret) || typeof timeoutS !== "number" || !Number.isFinite(timeoutS) || timeoutS < 1 || timeoutS > 3600) throw new Error("revocation_configuration_unavailable");
|
|
3420
|
+
return {
|
|
3421
|
+
remoteUrl,
|
|
3422
|
+
adminSecret,
|
|
3423
|
+
timeoutS
|
|
3424
|
+
};
|
|
3425
|
+
}
|
|
3426
|
+
function sameConfiguration(options, request) {
|
|
3427
|
+
try {
|
|
3428
|
+
const current = setup(options);
|
|
3429
|
+
return current.remoteUrl === request.remoteUrl && current.adminSecret === request.adminSecret && current.timeoutS === request.timeoutS;
|
|
3430
|
+
} catch {
|
|
3431
|
+
return false;
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
function failure(options, code, message, exitCode = 1) {
|
|
3435
|
+
if (options.json) {
|
|
3436
|
+
emitJsonError(code, message, exitCode);
|
|
3437
|
+
return;
|
|
3438
|
+
}
|
|
3439
|
+
p.log.error(message, { output: process.stderr });
|
|
3440
|
+
process.exitCode = exitCode;
|
|
3441
|
+
}
|
|
3442
|
+
var IMPACT = "Across the whole coordinator database: revoke this device ID with ANY key and this canonical key under ANY device ID, in ALL groups. The selected group supplies evidence only. Another device continues only when BOTH its device ID and key differ. No immediate offline enforcement, manual/direct-peer trust revocation, or erasure of remote copies. Account links, sessions, grants, memberships and stored memories are not rewritten.";
|
|
3443
|
+
function receiptLabel(value) {
|
|
3444
|
+
return JSON.stringify(value).replace(/[\p{Cc}\p{Cf}\p{Cs}\u2028\u2029]/gu, (character) => `\\u{${(character.codePointAt(0) ?? 0).toString(16)}}`);
|
|
3445
|
+
}
|
|
3446
|
+
function receiptJson(result) {
|
|
3447
|
+
return JSON.stringify(result).replace(/[\p{Cc}\p{Cf}\p{Cs}\u2028\u2029]/gu, (character) => character.split("").map((unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`).join(""));
|
|
3448
|
+
}
|
|
3449
|
+
async function confirmRevocation(options, request, preview, onDispatch) {
|
|
3450
|
+
const approved = await p.confirm({
|
|
3451
|
+
message: "Revoke BOTH subjects across the whole coordinator database?",
|
|
3452
|
+
initialValue: false,
|
|
3453
|
+
output: process.stderr
|
|
3454
|
+
});
|
|
3455
|
+
if (p.isCancel(approved) || approved !== true) {
|
|
3456
|
+
p.log.info("No revocation requested.", { output: process.stderr });
|
|
3457
|
+
return;
|
|
3458
|
+
}
|
|
3459
|
+
if (!sameConfiguration(options, request)) return failure(options, "revocation_configuration_changed", "Operator configuration changed or is unavailable. No revocation requested; preview again.");
|
|
3460
|
+
const result = await coordinatorRevokeDeviceAction({
|
|
3461
|
+
...request,
|
|
3462
|
+
expectedDigest: preview.expected_digest,
|
|
3463
|
+
expectedContext: {
|
|
3464
|
+
coordinatorId: preview.coordinator_id,
|
|
3465
|
+
keyId: preview.key_id
|
|
3466
|
+
},
|
|
3467
|
+
onDispatch
|
|
3468
|
+
});
|
|
3469
|
+
if (result.state !== "revoked") throw new Error("revocation_response_invalid");
|
|
3470
|
+
process.stdout.write(`${receiptJson(result)}\n`);
|
|
3471
|
+
p.log.success(`Coordinator ${result.coordinator_id}: both whole-database subjects revoked.`, { output: process.stderr });
|
|
3472
|
+
for (const row of result.revocations) p.log.info(`${row.subject_kind}: ${row.subject_value}; retained reference: ${receiptLabel(row.revocation_id)}; created: ${receiptLabel(row.created_at)}`, { output: process.stderr });
|
|
3473
|
+
}
|
|
3474
|
+
async function revokeDevice(groupId, deviceId, options) {
|
|
3475
|
+
let writeDispatched = false;
|
|
3476
|
+
try {
|
|
3477
|
+
if (!validId(groupId) || !validId(deviceId)) return failure(options, "revocation_invalid_input", "Use exact group and device IDs without whitespace or control characters (maximum 256 characters).", 2);
|
|
3478
|
+
const request = {
|
|
3479
|
+
...setup(options),
|
|
3480
|
+
groupId,
|
|
3481
|
+
deviceId
|
|
3482
|
+
};
|
|
3483
|
+
const preview = await coordinatorRevokeDeviceAction(request);
|
|
3484
|
+
if (preview.state !== "ready") throw new Error("revocation_response_invalid");
|
|
3485
|
+
if (options.json) {
|
|
3486
|
+
process.stdout.write(`${JSON.stringify({
|
|
3487
|
+
preview,
|
|
3488
|
+
network_request: true,
|
|
3489
|
+
unchanged: true,
|
|
3490
|
+
message: IMPACT
|
|
3491
|
+
})}\n`);
|
|
3492
|
+
return;
|
|
3493
|
+
}
|
|
3494
|
+
p.log.info(`Coordinator: ${preview.coordinator_id}\nEvidence group: ${preview.group_id}\nGlobal device ID: ${preview.device_id}\nCanonical key ID: ${preview.key_id}\nScope: ${preview.scope}`, { output: process.stderr });
|
|
3495
|
+
p.log.warn(IMPACT, { output: process.stderr });
|
|
3496
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
3497
|
+
p.log.info("Network preview only; no revocation requested. Use a terminal without --json to confirm.", { output: process.stderr });
|
|
3498
|
+
return;
|
|
3499
|
+
}
|
|
3500
|
+
await confirmRevocation(options, request, preview, () => {
|
|
3501
|
+
writeDispatched = true;
|
|
3502
|
+
});
|
|
3503
|
+
} catch (error) {
|
|
3504
|
+
if (error instanceof RemoteCoordinatorRequestError && error.status === 409 && (error.code === "revocation_preview_stale" || error.code === "revocation_target_unavailable")) return failure(options, error.code, "Revocation rejected: the target is unavailable or preview evidence changed. Preview again; do not retry automatically.");
|
|
3505
|
+
if (writeDispatched) return failure(options, "revocation_outcome_unknown", "Revocation outcome is unknown. It may have committed; do not assume rollback or retry automatically. Ask the coordinator operator to inspect both retained subjects.");
|
|
3506
|
+
failure(options, "revocation_unavailable", "Revocation preview unavailable or invalid. Check the saved HTTPS origin (HTTP only on literal loopback), operator credential and operator-enabled server context. The CLI does not activate the server. No revocation requested.");
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
function buildCoordinatorRevokeDeviceCommand() {
|
|
3510
|
+
const command = new Command("revoke-device").configureHelp(helpStyle).description("Preview and confirm whole-database device ID and key revocation").argument("<group>", "group supplying current enrollment evidence only").argument("<device>", "device ID to revoke across all groups");
|
|
3511
|
+
addConfigOption(command);
|
|
3512
|
+
addJsonOption(command);
|
|
3513
|
+
command.action(revokeDevice);
|
|
3514
|
+
return command;
|
|
3515
|
+
}
|
|
3516
|
+
//#endregion
|
|
2586
3517
|
//#region src/commands/coordinator.ts
|
|
2587
3518
|
/**
|
|
2588
3519
|
* Coordinator CLI commands — manage coordinator invites, join requests, and relay server.
|
|
@@ -2616,7 +3547,7 @@ function parseOptionalInteger(value, name) {
|
|
|
2616
3547
|
}
|
|
2617
3548
|
function formatImportInviteError(error) {
|
|
2618
3549
|
const message = error instanceof Error ? error.message : String(error);
|
|
2619
|
-
const
|
|
3550
|
+
const recipientInviteReviewGuidance = "Review this invitation with `codemem coordinator import-invite <invite> --review`, then accept with --reviewed-onboarding-digest <digest> using the same profile and device name. You can also review and accept through the Viewer with `codemem serve`.";
|
|
2620
3551
|
switch (message) {
|
|
2621
3552
|
case "invite_already_bound": return "This invite was already used. Ask the sender for a new one.";
|
|
2622
3553
|
case "invite_expired": return "This invite has expired. Ask the sender for a new one.";
|
|
@@ -2631,16 +3562,66 @@ function formatImportInviteError(error) {
|
|
|
2631
3562
|
case "invite_identity_conflict":
|
|
2632
3563
|
case "recipient_invite_intent_mismatch":
|
|
2633
3564
|
case "recipient_invite_review_unavailable":
|
|
2634
|
-
case "reviewed_onboarding_digest_required":
|
|
3565
|
+
case "reviewed_onboarding_digest_required":
|
|
3566
|
+
case "reviewed_onboarding_stale": return recipientInviteReviewGuidance;
|
|
3567
|
+
case "invite_review_profile_required": return "Invite review needs an initialized device and matching keys. Run `codemem sync pair --payload-only` with the intended --db-path, --config, and CODEMEM_KEYS_DIR first, then review that profile with --keys-dir.";
|
|
3568
|
+
case "recipient_invite_review_required": return "Only Team/add-device recipient invitations need --review. Import this legacy invitation without --review.";
|
|
3569
|
+
case "invite_inspection_transport_failed": return "Could not inspect the invitation at its coordinator. Check network access and use the original coordinator URL; redirects are not accepted.";
|
|
3570
|
+
case "invite_coordinator_conflict": return "This profile already uses another coordinator. Use the intended device profile and review again.";
|
|
2635
3571
|
default: return message;
|
|
2636
3572
|
}
|
|
2637
3573
|
}
|
|
3574
|
+
function inviteImportProfile(invite, opts) {
|
|
3575
|
+
return {
|
|
3576
|
+
inviteValue: invite,
|
|
3577
|
+
dbPath: resolveDbOpt(opts) ?? null,
|
|
3578
|
+
keysDir: opts.keysDir ?? null,
|
|
3579
|
+
configPath: opts.config ?? null,
|
|
3580
|
+
recipientDisplayName: opts.recipientName?.trim() || null,
|
|
3581
|
+
deviceDisplayName: opts.deviceName?.trim() || null
|
|
3582
|
+
};
|
|
3583
|
+
}
|
|
3584
|
+
async function acceptInviteWithProfileCheck(profile, reviewedOnboardingDigest) {
|
|
3585
|
+
const digest = reviewedOnboardingDigest?.trim() || null;
|
|
3586
|
+
if (digest) {
|
|
3587
|
+
if ((await coordinatorReviewInviteAction(profile)).reviewed_onboarding_digest !== digest) throw new Error("reviewed_onboarding_stale");
|
|
3588
|
+
}
|
|
3589
|
+
return coordinatorImportInviteAction({
|
|
3590
|
+
...profile,
|
|
3591
|
+
reviewedOnboardingDigest: digest
|
|
3592
|
+
});
|
|
3593
|
+
}
|
|
3594
|
+
function rejectConflictingInviteReviewOptions(opts) {
|
|
3595
|
+
if (!opts.review || opts.reviewedOnboardingDigest === void 0) return false;
|
|
3596
|
+
const message = "Use --review or --reviewed-onboarding-digest, not both.";
|
|
3597
|
+
if (opts.json) emitJsonError("usage_error", message);
|
|
3598
|
+
else p.log.error(message);
|
|
3599
|
+
process.exitCode = 2;
|
|
3600
|
+
return true;
|
|
3601
|
+
}
|
|
3602
|
+
function printInviteReview(review, opts) {
|
|
3603
|
+
console.log(JSON.stringify(review, null, 2));
|
|
3604
|
+
if (opts.json) return;
|
|
3605
|
+
p.outro("Review only: no invitation accepted or local profile changed. To accept, rerun with --reviewed-onboarding-digest <digest> and the same profile and device name. A Team recipient display name is cosmetic and may be changed before acceptance.");
|
|
3606
|
+
}
|
|
2638
3607
|
/**
|
|
2639
3608
|
* Build a fresh coordinator command tree. Each call returns independent
|
|
2640
3609
|
* Commander instances so the tree can be mounted under multiple parents.
|
|
2641
3610
|
*/
|
|
2642
|
-
function
|
|
3611
|
+
function createCoordinatorCommandRoot() {
|
|
2643
3612
|
const cmd = new Command("coordinator").configureHelp(helpStyle).description("Manage coordinator invites, join requests, and relay server");
|
|
3613
|
+
cmd.addCommand(buildCoordinatorLinkAccountCommand());
|
|
3614
|
+
cmd.addCommand(buildCoordinatorEnrollOwnerCommand());
|
|
3615
|
+
cmd.addCommand(buildCoordinatorReviewDeviceOwnerCommand());
|
|
3616
|
+
cmd.addCommand(buildCoordinatorQualifyOwnerCommand());
|
|
3617
|
+
cmd.addCommand(buildCoordinatorGrantIdentityGroupCommand());
|
|
3618
|
+
cmd.addCommand(buildCoordinatorRevokeDeviceCommand());
|
|
3619
|
+
cmd.addCommand(buildCoordinatorAuthSchemaCommand());
|
|
3620
|
+
cmd.addCommand(buildCoordinatorAccountSchemaCommand());
|
|
3621
|
+
return cmd;
|
|
3622
|
+
}
|
|
3623
|
+
function buildCoordinatorCommand() {
|
|
3624
|
+
const cmd = createCoordinatorCommandRoot();
|
|
2644
3625
|
const groupCreateCmd = new Command("group-create").configureHelp(helpStyle).description("Create a coordinator group in the local store").argument("<group>", "group id").option("--name <name>", "display name override");
|
|
2645
3626
|
addDbOption(groupCreateCmd);
|
|
2646
3627
|
addJsonOption(groupCreateCmd);
|
|
@@ -3265,20 +4246,19 @@ function buildCoordinatorCommand() {
|
|
|
3265
4246
|
}
|
|
3266
4247
|
});
|
|
3267
4248
|
cmd.addCommand(createInviteCmd);
|
|
3268
|
-
const importInviteCmd = new Command("import-invite").configureHelp(helpStyle).description("Import a coordinator invite").argument("<invite>", "invite value or link").option("-R, --recipient-name <name>", "recipient Identity display name").option("-N, --device-name <name>", "device display name").option("--keys-dir <path>", "keys directory");
|
|
4249
|
+
const importInviteCmd = new Command("import-invite").configureHelp(helpStyle).description("Import a coordinator invite").argument("<invite>", "invite value or link").option("-R, --recipient-name <name>", "recipient Identity display name").option("-N, --device-name <name>", "device display name").option("--review", "Review recipient access without accepting").option("--reviewed-onboarding-digest <digest>", "Accept the reviewed recipient access").option("--keys-dir <path>", "keys directory");
|
|
3269
4250
|
addDbOption(importInviteCmd);
|
|
3270
4251
|
addConfigOption(importInviteCmd);
|
|
3271
4252
|
addJsonOption(importInviteCmd);
|
|
3272
4253
|
importInviteCmd.action(async (invite, opts) => {
|
|
3273
4254
|
try {
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
});
|
|
4255
|
+
if (rejectConflictingInviteReviewOptions(opts)) return;
|
|
4256
|
+
const profile = inviteImportProfile(invite, opts);
|
|
4257
|
+
if (opts.review) {
|
|
4258
|
+
printInviteReview(await coordinatorReviewInviteAction(profile), opts);
|
|
4259
|
+
return;
|
|
4260
|
+
}
|
|
4261
|
+
const result = await acceptInviteWithProfileCheck(profile, opts.reviewedOnboardingDigest);
|
|
3282
4262
|
if (opts.json) {
|
|
3283
4263
|
console.log(JSON.stringify(result, null, 2));
|
|
3284
4264
|
return;
|
|
@@ -4250,11 +5230,11 @@ async function distillAction(opts) {
|
|
|
4250
5230
|
store?.close();
|
|
4251
5231
|
}
|
|
4252
5232
|
}
|
|
4253
|
-
var cmd$
|
|
4254
|
-
addDbOption(cmd$
|
|
4255
|
-
addJsonOption(cmd$
|
|
4256
|
-
cmd$
|
|
4257
|
-
var distillCommand = cmd$
|
|
5233
|
+
var cmd$5 = new Command("distill").configureHelp(helpStyle).description("Mine recurring memories into reviewable context candidates").option("-p, --project <project>", "project identifier (defaults to git repo root)").option("-A, --all-projects", "mine memories across all projects").option("-k, --kind <kind>", "memory kind to mine (repeat or comma-separate)", collectKind, []).option("-m, --min-recurrence <n>", "minimum member count per candidate", "2").option("-l, --limit <n>", "max candidates", "10").option("-e, --explain", "include evidence snippets in human output").option("--include-documented", "include candidates already represented in context files").option("--no-judge", "skip the observer-model worthiness judgment that drops routine-activity clusters").option("-D, --draft", "draft an AGENTS.md rule for the top candidate and show the diff").option("--apply", "write the drafted rule to the target file (implies --draft; prompts to confirm; with --json applies immediately without prompting)");
|
|
5234
|
+
addDbOption(cmd$5);
|
|
5235
|
+
addJsonOption(cmd$5);
|
|
5236
|
+
cmd$5.action(distillAction);
|
|
5237
|
+
var distillCommand = cmd$5;
|
|
4258
5238
|
//#endregion
|
|
4259
5239
|
//#region src/commands/embed.ts
|
|
4260
5240
|
function parseOptionalPositiveInt(value) {
|
|
@@ -4353,12 +5333,12 @@ function invokedAsTopLevelAlias(name, argv = process.argv.slice(2)) {
|
|
|
4353
5333
|
}
|
|
4354
5334
|
//#endregion
|
|
4355
5335
|
//#region src/commands/export-memories.ts
|
|
4356
|
-
function expandUserPath(value) {
|
|
5336
|
+
function expandUserPath$1(value) {
|
|
4357
5337
|
return value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
|
|
4358
5338
|
}
|
|
4359
|
-
var cmd$
|
|
4360
|
-
addDbOption(cmd$
|
|
4361
|
-
cmd$
|
|
5339
|
+
var cmd$4 = new Command("export-memories").configureHelp(helpStyle).description("Export memories to a JSON file for sharing or backup").argument("<output>", "output file path (use '-' for stdout)").option("--project <project>", "filter by project (defaults to git repo root)").option("--all-projects", "export all projects").option("--include-inactive", "include deactivated memories").option("--since <iso>", "only export memories created after this ISO timestamp");
|
|
5340
|
+
addDbOption(cmd$4);
|
|
5341
|
+
cmd$4.action((output, opts) => {
|
|
4362
5342
|
if (output !== "-" && invokedAsTopLevelAlias("export-memories")) emitDeprecationWarning("codemem export-memories", "codemem memory export");
|
|
4363
5343
|
const payload = exportMemories({
|
|
4364
5344
|
dbPath: resolveDbPath(resolveDbOpt(opts)),
|
|
@@ -4372,7 +5352,7 @@ cmd$2.action((output, opts) => {
|
|
|
4372
5352
|
process.stdout.write(text);
|
|
4373
5353
|
return;
|
|
4374
5354
|
}
|
|
4375
|
-
const outputPath = expandUserPath(output);
|
|
5355
|
+
const outputPath = expandUserPath$1(output);
|
|
4376
5356
|
writeFileSync(outputPath, text, "utf8");
|
|
4377
5357
|
p.intro("codemem export-memories");
|
|
4378
5358
|
p.log.success([
|
|
@@ -4384,13 +5364,13 @@ cmd$2.action((output, opts) => {
|
|
|
4384
5364
|
].join("\n"));
|
|
4385
5365
|
p.outro("done");
|
|
4386
5366
|
});
|
|
4387
|
-
var exportMemoriesCommand = cmd$
|
|
5367
|
+
var exportMemoriesCommand = cmd$4;
|
|
4388
5368
|
//#endregion
|
|
4389
5369
|
//#region src/commands/import-memories.ts
|
|
4390
|
-
var cmd$
|
|
4391
|
-
addDbOption(cmd$
|
|
4392
|
-
addJsonOption(cmd$
|
|
4393
|
-
cmd$
|
|
5370
|
+
var cmd$3 = new Command("import-memories").configureHelp(helpStyle).description("Import memories from an exported JSON file").argument("<inputFile>", "input JSON file (use '-' for stdin)").option("--remap-project <path>", "remap all projects to this path on import").option("--dry-run", "preview import without writing");
|
|
5371
|
+
addDbOption(cmd$3);
|
|
5372
|
+
addJsonOption(cmd$3);
|
|
5373
|
+
cmd$3.action((inputFile, opts) => {
|
|
4394
5374
|
if (!opts.json && invokedAsTopLevelAlias("import-memories")) emitDeprecationWarning("codemem import-memories", "codemem memory import");
|
|
4395
5375
|
let payload;
|
|
4396
5376
|
try {
|
|
@@ -4451,7 +5431,7 @@ cmd$1.action((inputFile, opts) => {
|
|
|
4451
5431
|
].join("\n"));
|
|
4452
5432
|
p.outro("done");
|
|
4453
5433
|
});
|
|
4454
|
-
var importMemoriesCommand = cmd$
|
|
5434
|
+
var importMemoriesCommand = cmd$3;
|
|
4455
5435
|
//#endregion
|
|
4456
5436
|
//#region src/maintenance-worker-runtime.ts
|
|
4457
5437
|
var defaultLogger = {
|
|
@@ -4957,7 +5937,7 @@ function forgetMemoryAction(idStr, opts) {
|
|
|
4957
5937
|
}
|
|
4958
5938
|
const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
|
|
4959
5939
|
try {
|
|
4960
|
-
if (!store.
|
|
5940
|
+
if (!store.forgetForUser(memoryId)) {
|
|
4961
5941
|
if (opts.json) emitJsonError("not_found", `Memory ${memoryId} not found`);
|
|
4962
5942
|
else {
|
|
4963
5943
|
p.log.error(`Memory ${memoryId} not found`);
|
|
@@ -4965,7 +5945,6 @@ function forgetMemoryAction(idStr, opts) {
|
|
|
4965
5945
|
}
|
|
4966
5946
|
return;
|
|
4967
5947
|
}
|
|
4968
|
-
store.forget(memoryId);
|
|
4969
5948
|
if (opts.json) console.log(JSON.stringify({
|
|
4970
5949
|
id: memoryId,
|
|
4971
5950
|
status: "forgotten"
|
|
@@ -4980,39 +5959,35 @@ function parseConfidence(value) {
|
|
|
4980
5959
|
if (!Number.isFinite(n)) return .5;
|
|
4981
5960
|
return Math.min(1, Math.max(0, n));
|
|
4982
5961
|
}
|
|
4983
|
-
function
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
store.db.prepare("DELETE FROM memory_concept_refs WHERE memory_id = ?").run(memoryId);
|
|
4989
|
-
store.db.prepare("DELETE FROM replication_ops WHERE entity_type = 'memory_item' AND (entity_id = ? OR entity_id = ?)").run(row?.import_key ?? "", String(memoryId));
|
|
4990
|
-
store.db.prepare("DELETE FROM memory_items WHERE id = ?").run(memoryId);
|
|
4991
|
-
store.db.prepare(`DELETE FROM sessions
|
|
4992
|
-
WHERE id = ?
|
|
4993
|
-
AND NOT EXISTS (SELECT 1 FROM memory_items WHERE session_id = ?)`).run(sessionId, sessionId);
|
|
4994
|
-
})();
|
|
4995
|
-
}
|
|
4996
|
-
async function rememberMemoryAction(opts) {
|
|
4997
|
-
const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
|
|
4998
|
-
let sessionId = null;
|
|
4999
|
-
try {
|
|
5000
|
-
const project = resolveProject(process.cwd(), opts.project ?? null);
|
|
5001
|
-
sessionId = store.startSession({
|
|
5962
|
+
function createManualMemory(store, opts) {
|
|
5963
|
+
const project = resolveProject(process.cwd(), opts.project ?? null);
|
|
5964
|
+
const createdMemories = [];
|
|
5965
|
+
return store.db.transaction(() => {
|
|
5966
|
+
const newSessionId = store.startSession({
|
|
5002
5967
|
cwd: process.cwd(),
|
|
5003
5968
|
project,
|
|
5004
5969
|
user: process.env.USER ?? "unknown",
|
|
5005
5970
|
toolVersion: "manual",
|
|
5006
5971
|
metadata: { manual: true }
|
|
5007
5972
|
});
|
|
5008
|
-
const memId = store.
|
|
5009
|
-
if (!store.get(memId))
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5973
|
+
const memId = store.rememberForUser(newSessionId, opts.kind, opts.title, opts.body, parseConfidence(opts.confidence), opts.tags, void 0, { createdMemories });
|
|
5974
|
+
if (!store.get(memId)) throw new Error("unauthorized_scope");
|
|
5975
|
+
store.endSession(newSessionId, { manual: true });
|
|
5976
|
+
return {
|
|
5977
|
+
memId,
|
|
5978
|
+
sessionId: newSessionId,
|
|
5979
|
+
createdMemories
|
|
5980
|
+
};
|
|
5981
|
+
}).immediate();
|
|
5982
|
+
}
|
|
5983
|
+
async function rememberMemoryAction(opts) {
|
|
5984
|
+
const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
|
|
5985
|
+
let sessionId = null;
|
|
5986
|
+
try {
|
|
5987
|
+
const result = createManualMemory(store, opts);
|
|
5988
|
+
sessionId = result.sessionId;
|
|
5989
|
+
const memId = result.memId;
|
|
5990
|
+
store.enqueueCommittedVectorWrites(result.createdMemories);
|
|
5016
5991
|
await store.flushPendingVectorWrites();
|
|
5017
5992
|
if (opts.json) console.log(JSON.stringify({ id: memId }));
|
|
5018
5993
|
else p.log.success(`Stored memory ${memId}`);
|
|
@@ -7172,6 +8147,218 @@ var piHookInjectCommand = piHookInjectCmd.action(async (opts) => {
|
|
|
7172
8147
|
}
|
|
7173
8148
|
});
|
|
7174
8149
|
//#endregion
|
|
8150
|
+
//#region src/commands/pi-import-sessions.ts
|
|
8151
|
+
/**
|
|
8152
|
+
* codemem pi-import-sessions — backfill pi agent session history (design D3/D4).
|
|
8153
|
+
*
|
|
8154
|
+
* Thin wrapper over core importPiSessions: walks every session JSONL file under
|
|
8155
|
+
* <pi-agent-dir>/sessions (project subdirs included, PI_CODING_AGENT_DIR honored).
|
|
8156
|
+
*
|
|
8157
|
+
* Extraction is opt-in (--extract, off by default): it runs the observer model over
|
|
8158
|
+
* the imported history, which costs tokens proportional to backlog size. With
|
|
8159
|
+
* --extract the STANDARD flush path (flushRawEvents — the same function the
|
|
8160
|
+
* raw-event sweeper calls each tick) runs over the imported pi sessions; there is
|
|
8161
|
+
* no bespoke extraction pipeline. Without it, imported events are stored searchable
|
|
8162
|
+
* and the running viewer's sweeper picks them up like any other backlog.
|
|
8163
|
+
*/
|
|
8164
|
+
var EXTRACT_COST_NOTE = "also extract memories (runs the observer model over imported history — LLM cost scales with backlog; default: off, imported events stay searchable)";
|
|
8165
|
+
/**
|
|
8166
|
+
* Import sessions, then — when --extract is set — drain the imported pi
|
|
8167
|
+
* sessions through the standard flush path. Returns the import summary plus
|
|
8168
|
+
* extraction counts and failures.
|
|
8169
|
+
*/
|
|
8170
|
+
async function runPiImportSessions(opts, deps = {}) {
|
|
8171
|
+
const dbPath = resolveDbPath(resolveDbOpt(opts));
|
|
8172
|
+
return {
|
|
8173
|
+
summary: importPiSessions({
|
|
8174
|
+
dbPath,
|
|
8175
|
+
onProgress: deps.onProgress
|
|
8176
|
+
}),
|
|
8177
|
+
extraction: opts.extract ? await flushImportedPiSessions(dbPath, deps.observer) : {
|
|
8178
|
+
requested: false,
|
|
8179
|
+
flushedEvents: 0,
|
|
8180
|
+
failedSessions: 0,
|
|
8181
|
+
pendingSessions: null,
|
|
8182
|
+
error: null
|
|
8183
|
+
}
|
|
8184
|
+
};
|
|
8185
|
+
}
|
|
8186
|
+
/**
|
|
8187
|
+
* Run the existing raw-event flush (the sweeper's own flushRawEvents) over
|
|
8188
|
+
* every pending source-"pi" session — never a bespoke extraction path. Fails
|
|
8189
|
+
* open per session: a failed flush batch stays retryable by the sweeper's
|
|
8190
|
+
* pending-queue phase, exactly like live-session flush failures.
|
|
8191
|
+
*/
|
|
8192
|
+
async function flushImportedPiSessions(dbPath, observer) {
|
|
8193
|
+
const store = new MemoryStore(dbPath);
|
|
8194
|
+
const extraction = {
|
|
8195
|
+
requested: true,
|
|
8196
|
+
flushedEvents: 0,
|
|
8197
|
+
failedSessions: 0,
|
|
8198
|
+
pendingSessions: 0,
|
|
8199
|
+
error: null
|
|
8200
|
+
};
|
|
8201
|
+
try {
|
|
8202
|
+
let resolved;
|
|
8203
|
+
try {
|
|
8204
|
+
resolved = observer ?? new ObserverClient();
|
|
8205
|
+
} catch (err) {
|
|
8206
|
+
extraction.error = `observer init failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
8207
|
+
extraction.pendingSessions = piPendingSessions(store).length;
|
|
8208
|
+
return extraction;
|
|
8209
|
+
}
|
|
8210
|
+
for (const streamId of piPendingSessions(store)) try {
|
|
8211
|
+
const result = await flushRawEvents(store, { observer: resolved }, {
|
|
8212
|
+
opencodeSessionId: streamId,
|
|
8213
|
+
source: "pi",
|
|
8214
|
+
cwd: null,
|
|
8215
|
+
project: null,
|
|
8216
|
+
startedAt: null,
|
|
8217
|
+
maxEvents: null
|
|
8218
|
+
});
|
|
8219
|
+
extraction.flushedEvents += result.flushed;
|
|
8220
|
+
} catch (err) {
|
|
8221
|
+
extraction.failedSessions++;
|
|
8222
|
+
extraction.error ??= `session ${streamId}: ${err instanceof Error ? err.message : String(err)}`;
|
|
8223
|
+
}
|
|
8224
|
+
extraction.pendingSessions = piPendingSessions(store).length;
|
|
8225
|
+
return extraction;
|
|
8226
|
+
} finally {
|
|
8227
|
+
store.close();
|
|
8228
|
+
}
|
|
8229
|
+
}
|
|
8230
|
+
/** Pending-flush sessions attributed to source "pi", without a page cap. */
|
|
8231
|
+
function piPendingSessions(store) {
|
|
8232
|
+
return store.rawEventSessionsPendingFlush(null, "pi").map((session) => session.streamId);
|
|
8233
|
+
}
|
|
8234
|
+
/** Human-readable summary line; per-file progress is streamed by the caller. */
|
|
8235
|
+
function formatPiImportHuman(result) {
|
|
8236
|
+
const { summary, extraction } = result;
|
|
8237
|
+
const lines = [`Scanned ${summary.filesScanned} files: ${summary.filesImported} imported, ${summary.filesUnchanged} unchanged, ${summary.filesEmpty} empty, ${summary.filesErrored} errored — ${summary.inserted} events inserted, ${summary.skipped} skipped.`];
|
|
8238
|
+
if (extraction.requested) {
|
|
8239
|
+
lines.push(`Extraction: observer flushed ${extraction.flushedEvents} events into memories.`);
|
|
8240
|
+
if (extraction.failedSessions > 0 || (extraction.pendingSessions ?? 0) > 0) lines.push(`${extraction.failedSessions} sessions failed; ${extraction.pendingSessions} remain pending.`);
|
|
8241
|
+
if (extraction.error) lines.push(`Extraction error: ${extraction.error}`);
|
|
8242
|
+
}
|
|
8243
|
+
return lines.join("\n");
|
|
8244
|
+
}
|
|
8245
|
+
function logProgress(progress) {
|
|
8246
|
+
const name = progress.file;
|
|
8247
|
+
if (progress.status === "imported") p.log.info(`imported ${name} (+${progress.inserted} events)`);
|
|
8248
|
+
else if (progress.status === "unchanged") p.log.info(`unchanged ${name}`);
|
|
8249
|
+
else if (progress.status === "empty") p.log.info(`empty ${name}`);
|
|
8250
|
+
else p.log.warn(`error ${name}: ${progress.error ?? "unknown error"}`);
|
|
8251
|
+
}
|
|
8252
|
+
var cmd$2 = new Command("pi-import-sessions").configureHelp(helpStyle).description("Import pi agent session history into the searchable store").option("--extract", EXTRACT_COST_NOTE);
|
|
8253
|
+
addDbOption(cmd$2);
|
|
8254
|
+
addJsonOption(cmd$2);
|
|
8255
|
+
var piImportSessionsCommand = cmd$2.action(async (opts) => {
|
|
8256
|
+
try {
|
|
8257
|
+
const result = await runPiImportSessions(opts, opts.json ? {} : { onProgress: logProgress });
|
|
8258
|
+
const { extraction } = result;
|
|
8259
|
+
let extractionFailure;
|
|
8260
|
+
if (extraction.error || extraction.failedSessions > 0 || (extraction.pendingSessions ?? 0) > 0) {
|
|
8261
|
+
extractionFailure = extraction.error ?? `${extraction.failedSessions} sessions failed; ${extraction.pendingSessions} remain pending.`;
|
|
8262
|
+
process.exitCode = 1;
|
|
8263
|
+
}
|
|
8264
|
+
if (opts.json) {
|
|
8265
|
+
console.log(JSON.stringify({
|
|
8266
|
+
...result.summary,
|
|
8267
|
+
extraction,
|
|
8268
|
+
error: extractionFailure ? "pi_extraction_incomplete" : void 0,
|
|
8269
|
+
message: extractionFailure
|
|
8270
|
+
}, null, 2));
|
|
8271
|
+
return;
|
|
8272
|
+
}
|
|
8273
|
+
p.log.message(formatPiImportHuman(result));
|
|
8274
|
+
} catch (error) {
|
|
8275
|
+
const message = error instanceof Error ? error.message : "pi-import-sessions failed";
|
|
8276
|
+
if (opts.json) emitJsonError("pi_import_sessions_failed", message);
|
|
8277
|
+
else {
|
|
8278
|
+
p.log.error(message);
|
|
8279
|
+
process.exitCode = 1;
|
|
8280
|
+
}
|
|
8281
|
+
}
|
|
8282
|
+
});
|
|
8283
|
+
//#endregion
|
|
8284
|
+
//#region src/commands/pi-session-search.ts
|
|
8285
|
+
/**
|
|
8286
|
+
* codemem pi-session-search — search stored pi session conversations.
|
|
8287
|
+
*
|
|
8288
|
+
* Queries the raw-event store (source "pi") via core searchPiSessions — the
|
|
8289
|
+
* same contract as GET /api/pi/sessions/search and the pi-extension
|
|
8290
|
+
* memory_session_search tool (design D5). --json prints the exact response
|
|
8291
|
+
* object those surfaces return, which is what the extension's CLI fallback
|
|
8292
|
+
* parses.
|
|
8293
|
+
*
|
|
8294
|
+
* Empty results usually mean the index only covers sessions captured since
|
|
8295
|
+
* codemem was installed; `codemem pi-import-sessions` backfills history.
|
|
8296
|
+
*/
|
|
8297
|
+
var EMPTY_INDEX_HINT = "The index may only cover sessions captured since codemem was installed; backfill history with: codemem pi-import-sessions";
|
|
8298
|
+
function intOption(value) {
|
|
8299
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
8300
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
8301
|
+
}
|
|
8302
|
+
/**
|
|
8303
|
+
* Run the session search and return the shared response object.
|
|
8304
|
+
* Throws on invalid input or store failure; printing stays with the caller.
|
|
8305
|
+
*/
|
|
8306
|
+
function runPiSessionSearch(query, opts) {
|
|
8307
|
+
if (!query.trim()) throw new Error("query required");
|
|
8308
|
+
const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
|
|
8309
|
+
try {
|
|
8310
|
+
return searchPiSessions(store.db, query, {
|
|
8311
|
+
project: opts.project,
|
|
8312
|
+
session_id: opts.sessionId,
|
|
8313
|
+
limit: intOption(opts.limit),
|
|
8314
|
+
snippet_chars: intOption(opts.snippetChars)
|
|
8315
|
+
});
|
|
8316
|
+
} finally {
|
|
8317
|
+
store.close();
|
|
8318
|
+
}
|
|
8319
|
+
}
|
|
8320
|
+
/** Human-readable output: "Found N results" header + attributed snippet lines. */
|
|
8321
|
+
function formatPiSessionSearchHuman(response) {
|
|
8322
|
+
const warning = response.query_truncated ? "Warning: matching used only part of the query. Shorten it to at most 8,192 characters and 64 search terms, then retry." : null;
|
|
8323
|
+
if (response.results.length === 0) return `No results found for "${response.query}". ${warning ?? EMPTY_INDEX_HINT}`;
|
|
8324
|
+
const lines = [`Found ${response.returned} results for "${response.query}"`];
|
|
8325
|
+
if (warning) lines.push(warning);
|
|
8326
|
+
for (const [index, match] of response.results.entries()) {
|
|
8327
|
+
const attribution = [
|
|
8328
|
+
match.role,
|
|
8329
|
+
match.project ?? "no project",
|
|
8330
|
+
`session ${match.session_id}`,
|
|
8331
|
+
match.timestamp ?? "unknown time"
|
|
8332
|
+
].join(" · ");
|
|
8333
|
+
const snippet = match.snippet_truncated ? `${match.snippet}…` : match.snippet;
|
|
8334
|
+
lines.push(`${index + 1}. ${attribution}`, snippet);
|
|
8335
|
+
}
|
|
8336
|
+
if (response.truncated) lines.push(`Showing ${response.returned} of ${response.total_matches} matches.`);
|
|
8337
|
+
return lines.join("\n");
|
|
8338
|
+
}
|
|
8339
|
+
var cmd$1 = new Command("pi-session-search").configureHelp(helpStyle).description("Search stored pi session conversations by text query").argument("<query>", "search query").option("--project <project>", "filter by project label").option("--session-id <id>", "filter by pi session id").option("--limit <n>", "max results (1-20)", "10").option("--snippet-chars <n>", "per-result snippet cap in chars (100-4000)", "1200");
|
|
8340
|
+
addDbOption(cmd$1);
|
|
8341
|
+
addJsonOption(cmd$1);
|
|
8342
|
+
var piSessionSearchCommand = cmd$1.action((query, opts) => {
|
|
8343
|
+
try {
|
|
8344
|
+
const response = runPiSessionSearch(query, opts);
|
|
8345
|
+
if (opts.json) {
|
|
8346
|
+
console.log(JSON.stringify(response, null, 2));
|
|
8347
|
+
return;
|
|
8348
|
+
}
|
|
8349
|
+
const text = formatPiSessionSearchHuman(response);
|
|
8350
|
+
if (response.results.length === 0) p.log.warn(text);
|
|
8351
|
+
else p.log.message(text);
|
|
8352
|
+
} catch (error) {
|
|
8353
|
+
const message = error instanceof Error ? error.message : "pi-session-search failed";
|
|
8354
|
+
if (opts.json) emitJsonError("pi_session_search_failed", message);
|
|
8355
|
+
else {
|
|
8356
|
+
p.log.error(message);
|
|
8357
|
+
process.exitCode = 1;
|
|
8358
|
+
}
|
|
8359
|
+
}
|
|
8360
|
+
});
|
|
8361
|
+
//#endregion
|
|
7175
8362
|
//#region src/commands/recent.ts
|
|
7176
8363
|
var cmd = new Command("recent").configureHelp(helpStyle).description("Show recent memories").option("--limit <n>", "max results", "5").option("--project <project>", "project identifier (defaults to git repo root)").option("--all-projects", "search across all projects").option("--kind <kind>", "filter by memory kind");
|
|
7177
8364
|
addDbOption(cmd);
|
|
@@ -8020,6 +9207,49 @@ function createRawEventTargetState(store) {
|
|
|
8020
9207
|
stop
|
|
8021
9208
|
};
|
|
8022
9209
|
}
|
|
9210
|
+
function createViewerAccountLink(store, invocation, serverModule) {
|
|
9211
|
+
const configPath = resolveCodememConfigPath(invocation.configPath ?? void 0).resolved.path;
|
|
9212
|
+
const keysDir = dirname(resolveKeyPaths(process.env.CODEMEM_KEYS_DIR?.trim() || void 0)[0]);
|
|
9213
|
+
const readContext = serverModule.createAccountContextReader({
|
|
9214
|
+
getProfile: () => ({
|
|
9215
|
+
dbPath: store.dbPath,
|
|
9216
|
+
deviceId: store.deviceId,
|
|
9217
|
+
identityId: store.actorId,
|
|
9218
|
+
identityLabel: store.actorDisplayName
|
|
9219
|
+
}),
|
|
9220
|
+
readConfig: () => readCodememConfigFileAtPath(configPath),
|
|
9221
|
+
configPath,
|
|
9222
|
+
keysDir
|
|
9223
|
+
});
|
|
9224
|
+
const registry = serverModule.createAccountLinkRegistry({ readContext });
|
|
9225
|
+
let actualBoundPort = null;
|
|
9226
|
+
return {
|
|
9227
|
+
registry,
|
|
9228
|
+
setBoundPort: (port) => {
|
|
9229
|
+
actualBoundPort = port;
|
|
9230
|
+
},
|
|
9231
|
+
options: {
|
|
9232
|
+
readContext,
|
|
9233
|
+
registry,
|
|
9234
|
+
getListenerAddress: () => actualBoundPort === null ? null : {
|
|
9235
|
+
host: invocation.host,
|
|
9236
|
+
port: actualBoundPort
|
|
9237
|
+
}
|
|
9238
|
+
}
|
|
9239
|
+
};
|
|
9240
|
+
}
|
|
9241
|
+
function announceViewerStartup(options) {
|
|
9242
|
+
const { pidPath, invocation, preparedDb, info } = options;
|
|
9243
|
+
writeFileSync(pidPath, JSON.stringify({
|
|
9244
|
+
pid: process.pid,
|
|
9245
|
+
host: invocation.host,
|
|
9246
|
+
port: invocation.port
|
|
9247
|
+
}), "utf-8");
|
|
9248
|
+
p.intro("codemem viewer");
|
|
9249
|
+
p.log.success(`Listening on http://${info.address}:${info.port}`);
|
|
9250
|
+
p.log.info(`Database: ${preparedDb}`);
|
|
9251
|
+
p.log.step("Raw event sweeper started");
|
|
9252
|
+
}
|
|
8023
9253
|
async function startForegroundViewer(invocation) {
|
|
8024
9254
|
const serverModule = await import("@codemem/server");
|
|
8025
9255
|
const { createApp, createSyncApp, closeStore, createViewerRawEventInbox, getStore } = serverModule;
|
|
@@ -8053,11 +9283,13 @@ async function startForegroundViewer(invocation) {
|
|
|
8053
9283
|
const config = invocation.configPath ? readCodememConfigFileAtPath(invocation.configPath) : readCodememConfigFile();
|
|
8054
9284
|
const syncConfig = readCoordinatorSyncConfig(config);
|
|
8055
9285
|
const syncEnabled = syncConfig.syncEnabled;
|
|
9286
|
+
const accountLink = createViewerAccountLink(store, invocation, serverModule);
|
|
8056
9287
|
const syncRuntimeStatus = {
|
|
8057
9288
|
phase: syncEnabled ? "starting" : "disabled",
|
|
8058
9289
|
detail: syncEnabled ? "Waiting for viewer startup to finish" : "Sync is disabled"
|
|
8059
9290
|
};
|
|
8060
9291
|
const appOpts = {
|
|
9292
|
+
accountLink: accountLink.options,
|
|
8061
9293
|
storeFactory: () => store,
|
|
8062
9294
|
...rawEventProcessing.appOptions,
|
|
8063
9295
|
getSyncRuntimeStatus: () => syncRuntimeStatus
|
|
@@ -8086,15 +9318,13 @@ async function startForegroundViewer(invocation) {
|
|
|
8086
9318
|
hostname: invocation.host,
|
|
8087
9319
|
port: invocation.port
|
|
8088
9320
|
}, (info) => {
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
p.log.info(`Database: ${preparedDb}`);
|
|
8097
|
-
p.log.step("Raw event sweeper started");
|
|
9321
|
+
accountLink.setBoundPort(info.port);
|
|
9322
|
+
announceViewerStartup({
|
|
9323
|
+
pidPath,
|
|
9324
|
+
invocation,
|
|
9325
|
+
preparedDb,
|
|
9326
|
+
info
|
|
9327
|
+
});
|
|
8098
9328
|
maintenanceWorker = startMaintenanceWorkerProcess(invocation);
|
|
8099
9329
|
if (syncEnabled) {
|
|
8100
9330
|
const syncStartDelayMs = 3e3;
|
|
@@ -8143,6 +9373,7 @@ async function startForegroundViewer(invocation) {
|
|
|
8143
9373
|
const shutdown = async () => {
|
|
8144
9374
|
p.outro("shutting down");
|
|
8145
9375
|
syncAbort.abort();
|
|
9376
|
+
await accountLink.registry.close();
|
|
8146
9377
|
await stopMaintenanceWorkerProcess(maintenanceWorker, dbPath);
|
|
8147
9378
|
await new Promise((resolve) => {
|
|
8148
9379
|
let remaining = syncServer ? 2 : 1;
|
|
@@ -9422,6 +10653,7 @@ function installPi(options = {}) {
|
|
|
9422
10653
|
p.log.info(" - Start (or restart) pi to load the extension package");
|
|
9423
10654
|
if (!options.piMcp) p.log.info(" - Optional MCP surface: install pi-mcp-adapter, then re-run with --pi-mcp");
|
|
9424
10655
|
p.log.info(" - To disable: remove the @codemem/pi-extension entry from pi settings.json packages");
|
|
10656
|
+
p.log.info(" - Pre-install pi session history is not imported automatically; backfill it with: codemem pi-import-sessions");
|
|
9425
10657
|
}
|
|
9426
10658
|
return ok;
|
|
9427
10659
|
}
|
|
@@ -10470,37 +11702,99 @@ doctorCmd.action(async (opts) => {
|
|
|
10470
11702
|
}
|
|
10471
11703
|
});
|
|
10472
11704
|
syncCommand.addCommand(doctorCmd);
|
|
10473
|
-
|
|
10474
|
-
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
|
|
10478
|
-
|
|
10479
|
-
const
|
|
10480
|
-
|
|
10481
|
-
|
|
10482
|
-
|
|
10483
|
-
|
|
10484
|
-
|
|
10485
|
-
|
|
10486
|
-
|
|
10487
|
-
|
|
10488
|
-
|
|
10489
|
-
|
|
10490
|
-
|
|
10491
|
-
|
|
10492
|
-
|
|
10493
|
-
|
|
10494
|
-
|
|
11705
|
+
function syncStatusIdentityAndPeers(store) {
|
|
11706
|
+
const d = drizzle(store.db, { schema });
|
|
11707
|
+
const deviceRow = d.select({
|
|
11708
|
+
device_id: schema.syncDevice.device_id,
|
|
11709
|
+
fingerprint: schema.syncDevice.fingerprint
|
|
11710
|
+
}).from(schema.syncDevice).limit(1).get();
|
|
11711
|
+
const peers = d.select({
|
|
11712
|
+
peer_device_id: schema.syncPeers.peer_device_id,
|
|
11713
|
+
name: schema.syncPeers.name,
|
|
11714
|
+
last_sync_at: schema.syncPeers.last_sync_at,
|
|
11715
|
+
last_error: schema.syncPeers.last_error,
|
|
11716
|
+
public_key: schema.syncPeers.public_key
|
|
11717
|
+
}).from(schema.syncPeers).all();
|
|
11718
|
+
const localDeviceId = deviceRow?.device_id ?? null;
|
|
11719
|
+
const localSigningPublicKey = localDeviceId && peers.length > 0 && store.db.prepare(`SELECT 1 FROM scope_memberships m
|
|
11720
|
+
JOIN replication_scopes s ON s.scope_id = m.scope_id
|
|
11721
|
+
WHERE m.device_id = ? AND m.status = 'active'
|
|
11722
|
+
AND s.authority_type = 'coordinator' LIMIT 1`).get(localDeviceId) ? loadRuntimeSigningPublicKey(store.db, {
|
|
11723
|
+
deviceId: localDeviceId,
|
|
11724
|
+
keysDir: process.env.CODEMEM_KEYS_DIR?.trim() || void 0
|
|
11725
|
+
}) : null;
|
|
11726
|
+
return {
|
|
11727
|
+
deviceRow,
|
|
11728
|
+
peers,
|
|
11729
|
+
peersWithScopes: peers.map((peer) => {
|
|
10495
11730
|
const peerDeviceId = String(peer.peer_device_id ?? "").trim();
|
|
10496
11731
|
return {
|
|
10497
11732
|
peer,
|
|
10498
11733
|
scopes: listPerPeerScopeSyncState(store.db, {
|
|
10499
11734
|
localDeviceId,
|
|
10500
|
-
peerDeviceId
|
|
11735
|
+
peerDeviceId,
|
|
11736
|
+
localSigningPublicKey,
|
|
11737
|
+
authenticatedPeerSigningKey: peer.public_key
|
|
10501
11738
|
})
|
|
10502
11739
|
};
|
|
11740
|
+
})
|
|
11741
|
+
};
|
|
11742
|
+
}
|
|
11743
|
+
function printSyncStatusPeers(peersWithScopes) {
|
|
11744
|
+
if (peersWithScopes.length === 0) {
|
|
11745
|
+
p.log.info("Peers: none");
|
|
11746
|
+
return;
|
|
11747
|
+
}
|
|
11748
|
+
for (const { peer, scopes } of peersWithScopes) {
|
|
11749
|
+
const peerHeader = ` ${peer.name || peer.peer_device_id}: last_sync=${peer.last_sync_at ?? "never"}, status=${peer.last_error ?? "ok"}`;
|
|
11750
|
+
if (scopes.length === 0) {
|
|
11751
|
+
p.log.message(peerHeader);
|
|
11752
|
+
continue;
|
|
11753
|
+
}
|
|
11754
|
+
const scopeLines = scopes.map((scope) => {
|
|
11755
|
+
const state = scope.bootstrapped ? "received" : "pending";
|
|
11756
|
+
return ` - ${scope.label} (${scope.scope_id}): ${state}`;
|
|
10503
11757
|
});
|
|
11758
|
+
p.log.message([
|
|
11759
|
+
peerHeader,
|
|
11760
|
+
" Spaces:",
|
|
11761
|
+
...scopeLines
|
|
11762
|
+
].join("\n"));
|
|
11763
|
+
}
|
|
11764
|
+
}
|
|
11765
|
+
function printSyncStatus(config, identityAndPeers, semanticIndex) {
|
|
11766
|
+
const { deviceRow, peers, peersWithScopes } = identityAndPeers;
|
|
11767
|
+
p.intro("codemem sync status");
|
|
11768
|
+
p.log.info([
|
|
11769
|
+
`Enabled: ${config.sync_enabled === true ? "yes" : "no"}`,
|
|
11770
|
+
`Host: ${config.sync_host ?? "0.0.0.0"}`,
|
|
11771
|
+
`Port: ${config.sync_port ?? 7337}`,
|
|
11772
|
+
`Interval: ${config.sync_interval_s ?? 120}s`,
|
|
11773
|
+
`Coordinator: ${config.sync_coordinator_url ?? "(not configured)"}`
|
|
11774
|
+
].join("\n"));
|
|
11775
|
+
if (deviceRow) p.log.info(`Device ID: ${deviceRow.device_id}\nFingerprint: ${deviceRow.fingerprint}`);
|
|
11776
|
+
else p.log.warn("Device identity not initialized (run `codemem sync enable`)");
|
|
11777
|
+
printSyncStatusPeers(peersWithScopes);
|
|
11778
|
+
p.log.info([
|
|
11779
|
+
"Semantic index:",
|
|
11780
|
+
` State: ${semanticIndex.state}`,
|
|
11781
|
+
` Summary: ${semanticIndex.summary}`,
|
|
11782
|
+
` Coverage: ${semanticIndex.indexed_memory_count}/${semanticIndex.embeddable_memory_count}`,
|
|
11783
|
+
` Mode: ${semanticIndex.mode === "semantic" ? `semantic (${semanticIndex.semantic_search_model ?? semanticIndex.current_model})` : "keyword-only"}`
|
|
11784
|
+
].join("\n"));
|
|
11785
|
+
p.outro(`${peers.length} peer(s)`);
|
|
11786
|
+
}
|
|
11787
|
+
var statusCmd = new Command("status").configureHelp(helpStyle).description("Show sync configuration and peer summary");
|
|
11788
|
+
addDbOption(statusCmd);
|
|
11789
|
+
addConfigOption(statusCmd);
|
|
11790
|
+
addJsonOption(statusCmd);
|
|
11791
|
+
statusCmd.action(function showSyncStatus(opts) {
|
|
11792
|
+
const config = readCliConfig(opts.config);
|
|
11793
|
+
const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
|
|
11794
|
+
try {
|
|
11795
|
+
const identityAndPeers = syncStatusIdentityAndPeers(store);
|
|
11796
|
+
const { deviceRow, peersWithScopes } = identityAndPeers;
|
|
11797
|
+
const semanticIndex = getSemanticIndexDiagnostics(store.db);
|
|
10504
11798
|
if (opts.json) {
|
|
10505
11799
|
console.log(JSON.stringify({
|
|
10506
11800
|
enabled: config.sync_enabled === true,
|
|
@@ -10521,40 +11815,7 @@ statusCmd.action((opts) => {
|
|
|
10521
11815
|
}, null, 2));
|
|
10522
11816
|
return;
|
|
10523
11817
|
}
|
|
10524
|
-
|
|
10525
|
-
p.log.info([
|
|
10526
|
-
`Enabled: ${config.sync_enabled === true ? "yes" : "no"}`,
|
|
10527
|
-
`Host: ${config.sync_host ?? "0.0.0.0"}`,
|
|
10528
|
-
`Port: ${config.sync_port ?? 7337}`,
|
|
10529
|
-
`Interval: ${config.sync_interval_s ?? 120}s`,
|
|
10530
|
-
`Coordinator: ${config.sync_coordinator_url ?? "(not configured)"}`
|
|
10531
|
-
].join("\n"));
|
|
10532
|
-
if (deviceRow) p.log.info(`Device ID: ${deviceRow.device_id}\nFingerprint: ${deviceRow.fingerprint}`);
|
|
10533
|
-
else p.log.warn("Device identity not initialized (run `codemem sync enable`)");
|
|
10534
|
-
if (peers.length === 0) p.log.info("Peers: none");
|
|
10535
|
-
else for (const { peer, scopes } of peersWithScopes) {
|
|
10536
|
-
const peerHeader = ` ${peer.name || peer.peer_device_id}: last_sync=${peer.last_sync_at ?? "never"}, status=${peer.last_error ?? "ok"}`;
|
|
10537
|
-
if (scopes.length === 0) p.log.message(peerHeader);
|
|
10538
|
-
else {
|
|
10539
|
-
const scopeLines = scopes.map((scope) => {
|
|
10540
|
-
const state = scope.bootstrapped ? "received" : "pending";
|
|
10541
|
-
return ` - ${scope.label} (${scope.scope_id}): ${state}`;
|
|
10542
|
-
});
|
|
10543
|
-
p.log.message([
|
|
10544
|
-
peerHeader,
|
|
10545
|
-
" Spaces:",
|
|
10546
|
-
...scopeLines
|
|
10547
|
-
].join("\n"));
|
|
10548
|
-
}
|
|
10549
|
-
}
|
|
10550
|
-
p.log.info([
|
|
10551
|
-
"Semantic index:",
|
|
10552
|
-
` State: ${semanticIndex.state}`,
|
|
10553
|
-
` Summary: ${semanticIndex.summary}`,
|
|
10554
|
-
` Coverage: ${semanticIndex.indexed_memory_count}/${semanticIndex.embeddable_memory_count}`,
|
|
10555
|
-
` Mode: ${semanticIndex.mode === "semantic" ? `semantic (${semanticIndex.semantic_search_model ?? semanticIndex.current_model})` : "keyword-only"}`
|
|
10556
|
-
].join("\n"));
|
|
10557
|
-
p.outro(`${peers.length} peer(s)`);
|
|
11818
|
+
printSyncStatus(config, identityAndPeers, semanticIndex);
|
|
10558
11819
|
} finally {
|
|
10559
11820
|
store.close();
|
|
10560
11821
|
}
|
|
@@ -10902,6 +12163,7 @@ connectCmd.action((url, opts) => {
|
|
|
10902
12163
|
syncCommand.addCommand(connectCmd);
|
|
10903
12164
|
var syncCoordinatorAlias = buildCoordinatorCommand();
|
|
10904
12165
|
syncCoordinatorAlias.hook("preAction", (_thisCmd, actionCmd) => {
|
|
12166
|
+
if (actionCmd.optsWithGlobals().json) return;
|
|
10905
12167
|
const subName = actionCmd.name();
|
|
10906
12168
|
emitDeprecationWarning(`codemem sync coordinator ${subName}`, `codemem coordinator ${subName}`);
|
|
10907
12169
|
});
|
|
@@ -11726,6 +12988,8 @@ var ROOT_COMPLETION_COMMANDS = [
|
|
|
11726
12988
|
"mcp",
|
|
11727
12989
|
"memory",
|
|
11728
12990
|
"pack",
|
|
12991
|
+
"pi-import-sessions",
|
|
12992
|
+
"pi-session-search",
|
|
11729
12993
|
"recent",
|
|
11730
12994
|
"search",
|
|
11731
12995
|
"serve",
|
|
@@ -11790,6 +13054,8 @@ function registerRootCommands(program) {
|
|
|
11790
13054
|
program.addCommand(recentCommand);
|
|
11791
13055
|
program.addCommand(searchCommand);
|
|
11792
13056
|
program.addCommand(packCommand);
|
|
13057
|
+
program.addCommand(piSessionSearchCommand);
|
|
13058
|
+
program.addCommand(piImportSessionsCommand);
|
|
11793
13059
|
program.addCommand(promptPackLedgerCommand, { hidden: true });
|
|
11794
13060
|
program.addCommand(showMemoryCommand, { hidden: true });
|
|
11795
13061
|
program.addCommand(forgetMemoryCommand, { hidden: true });
|