diff --git a/.github/scripts/factory-production-intake.mjs b/.github/scripts/factory-production-intake.mjs new file mode 100644 index 0000000..71f5958 --- /dev/null +++ b/.github/scripts/factory-production-intake.mjs @@ -0,0 +1,109 @@ +// Generated from Factory production intake. SHA256: e0a02d6fea6ce2bcfd4782b139c805cb1b737e39a3f92ea1ee56035edd7c1294 +/** Runs in the owning repository with read-only GitHub access. No cloud or npm credentials. */ +export async function collectProduction(repository, token, workflow, jobName, runId) { + const get = async (path) => { + const response = await fetch(`https://api.github.com/repos/${repository}/${path}`, { redirect: 'error', signal: AbortSignal.timeout(20000), + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } }); + if (!response.ok) + throw new Error(`GitHub production read failed (${response.status}).`); + return response.json(); + }; + const ownedRun = (r) => r && r.head_branch === 'master' && ['push', 'workflow_dispatch'].includes(r.event) + && r.path === `.github/workflows/${workflow}` && r.head_repository?.full_name === repository; + const { workflow_runs: history } = await get(`actions/workflows/${workflow}/runs?branch=master&per_page=30`); + const run = runId ? await get(`actions/runs/${runId}`) : history.find(ownedRun); + if (!ownedRun(run)) + return null; + const jobs = async (r) => (await get(`actions/runs/${r.id}/attempts/${r.run_attempt}/jobs?per_page=100`)); + const deploymentJobs = (r, result) => result.jobs.filter((j) => j.name === jobName && j.head_sha === r.head_sha); + const current = await jobs(run); + if (current.total_count > 100) + throw new Error('Production workflow has more jobs than the collector supports.'); + const matching = deploymentJobs(run, current); + if (matching.length > 1) + throw new Error('Production job name must be unique.'); + const job = matching[0]; + const state = run.status !== 'completed' ? 'running' : run.conclusion !== 'success' ? 'failed' + : job?.status === 'completed' && job.conclusion === 'success' ? 'deployed' : 'skipped'; + const pullRequests = []; + let linkageComplete = false; + // Only an earlier successful deployment establishes the range of newly shipped commits. + // The first tracked release retains its build receipt but requires a person to review its ticket scope. + if (state === 'deployed') { + let previous; + // A rerun finishes at a different time than its original creation. Compare the latest + // completed receipts so a later rerun cannot become an earlier deployment boundary. + const earlier = history.filter((r) => ownedRun(r) && r.id !== run.id && r.updated_at < run.updated_at && r.status === 'completed' && r.conclusion === 'success') + .sort((a, b) => b.updated_at.localeCompare(a.updated_at)).slice(0, 10); + for (const candidate of earlier) { + const prior = await jobs(candidate); + const matches = deploymentJobs(candidate, prior); + if (prior.total_count <= 100 && matches.length === 1 && matches[0].status === 'completed' && matches[0].conclusion === 'success') { + previous = candidate; + break; + } + } + if (previous) { + const diff = await get(`compare/${previous.head_sha}...${run.head_sha}?per_page=100`); + if (['ahead', 'identical'].includes(diff.status) && diff.total_commits <= 100) { + linkageComplete = true; + const found = new Map(); + for (const commit of diff.commits) { + const prs = await get(`commits/${commit.sha}/pulls?per_page=100`); + if (prs.length >= 100) { + linkageComplete = false; + break; + } + for (const pr of prs) + if (pr.merged_at && ['staging', 'master'].includes(pr.base?.ref) && pr.base?.repo?.full_name === repository && pr.merge_commit_sha && diff.commits.some((c) => c.sha === pr.merge_commit_sha)) + found.set(pr.number, pr); + } + if (found.size > 50) + linkageComplete = false; + if (linkageComplete) + for (const pr of found.values()) { + const rows = await get(`pulls/${pr.number}/files?per_page=100`); + if (rows.length >= 100) + linkageComplete = false; + pullRequests.push({ number: pr.number, title: pr.title.slice(0, 300), body: (pr.body ?? '').slice(0, 20000), headRef: pr.head.ref, + mergeSha: pr.merge_commit_sha, files: rows.length < 100 ? rows.map((f) => f.filename) : null }); + } + } + } + } + return { repository, workflow, runId: String(run.id), attempt: run.run_attempt, candidate: run.head_sha, state, + url: `https://github.com/${repository}/actions/runs/${run.id}`, createdAt: run.created_at, updatedAt: run.updated_at, + observedAt: new Date().toISOString(), job: job ? { name: job.name, status: job.status, conclusion: job.conclusion } : null, linkageComplete, pullRequests }; +} + +// Appended to the compiled production collector by scripts/sync-production-intake.mjs. +const endpoint = 'https://factory-rover-81024286635.us-west1.run.app/github/production-intake'; +try { + let runIds = [process.env.FACTORY_RUN_ID].filter(Boolean); + if (!runIds.length) { + const history = await fetch(`https://api.github.com/repos/${process.env.GITHUB_REPOSITORY}/actions/workflows/${process.env.FACTORY_PRODUCTION_WORKFLOW}/runs?branch=master&per_page=30`, { + redirect: 'error', signal: AbortSignal.timeout(20000), headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Accept: 'application/vnd.github+json' } }); + if (!history.ok) throw new Error(`GitHub production history failed (${history.status}).`); + runIds = (await history.json()).workflow_runs.filter(r => ['push', 'workflow_dispatch'].includes(r.event) && Date.now() - Date.parse(r.created_at) < 7 * 86400000) + .map(r => String(r.id)).reverse(); + } + let failed = false; + for (const runId of runIds) try { + const payload = await collectProduction(process.env.GITHUB_REPOSITORY, process.env.GITHUB_TOKEN, process.env.FACTORY_PRODUCTION_WORKFLOW, process.env.FACTORY_PRODUCTION_JOB, runId); + if (!payload) continue; + const request = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL); + request.searchParams.set('audience', 'https://factory.dealmachine.com/production-intake'); + const identity = await fetch(request, { headers: { Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}` }, signal: AbortSignal.timeout(20000) }); + if (!identity.ok) throw new Error(`GitHub identity request failed (${identity.status}).`); + const { value } = await identity.json(); + if (!value) throw new Error('GitHub returned no intake identity.'); + let body = JSON.stringify(payload); + if (Buffer.byteLength(body) > 500000) { + payload.pullRequests = []; payload.linkageComplete = false; body = JSON.stringify(payload); + } + const response = await fetch(endpoint, { method: 'POST', headers: { Authorization: `Bearer ${value}`, 'Content-Type': 'application/json' }, body, signal: AbortSignal.timeout(60000) }); + if (!response.ok) throw new Error(`Factory production intake failed (${response.status}); scheduled reconciliation will retry.`); + console.log(`Recorded production run ${payload.runId}, attempt ${payload.attempt}: ${payload.state}.`); + } catch (error) { failed = true; console.error(`Production run ${runId}: ${error.message}`); } + if (failed) process.exitCode = 1; +} catch (error) { console.error(error.message); process.exitCode = 1; } diff --git a/.github/scripts/factory-release-intake.mjs b/.github/scripts/factory-release-intake.mjs new file mode 100644 index 0000000..82c32d0 --- /dev/null +++ b/.github/scripts/factory-release-intake.mjs @@ -0,0 +1,98 @@ +// Generated from Factory staging intake. Source SHA256: 7ef6161ca1c9aca6af23c8742bb5db246697ea32f6edb611c4acc5d7a64711b9 +/** Reads only GitHub metadata. The same collector runs in each owning repository and in recovery. */ +export async function collectStaging(repository, token, deploymentWorkflow, recoveryCheck = null) { + const get = async (path) => { + const response = await fetch(`https://api.github.com/repos/${repository}/${path}`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, + redirect: 'error', signal: AbortSignal.timeout(20_000), + }); + if (!response.ok) + throw new Error(`GitHub staging read failed (${response.status}).`); + return response.json(); + }; + const [stage, production] = await Promise.all([get('branches/staging'), get('branches/master')]); + const stagingSha = stage.commit.sha; + const contains = async (ancestor, head) => { + if (ancestor === head) + return true; + const comparison = await get(`compare/${ancestor}...${head}`); + return ['ahead', 'identical'].includes(comparison.status); + }; + const pullRequests = []; + const resolved = []; + for (let page = 1;; page++) { + const rows = await get(`pulls?state=closed&base=staging&sort=updated&direction=desc&per_page=100&page=${page}`); + for (const pr of rows) { + if (!pr.merged_at || !pr.merge_commit_sha || pr.base?.ref !== 'staging') + continue; + if (!await contains(pr.merge_commit_sha, stagingSha)) { + resolved.push({ number: pr.number, disposition: 'removed-from-staging' }); + continue; + } + if (await contains(pr.merge_commit_sha, production.commit.sha)) { + resolved.push({ number: pr.number, disposition: 'on-master' }); + continue; + } + pullRequests.push({ number: pr.number, title: pr.title, body: (pr.body ?? '').slice(0, 20000), headRef: pr.head.ref, + mergeSha: pr.merge_commit_sha, mergedAt: pr.merged_at }); + } + if (rows.length < 100) + break; + if (page >= 20) + throw new Error('Staging PR history exceeds the reconciliation limit. An owner must partition the history.'); + } + let deployment = null; + if (deploymentWorkflow) { + const { workflow_runs: runs } = await get(`actions/workflows/${deploymentWorkflow}/runs?branch=staging&per_page=30`); + const run = runs.find((r) => r.head_branch === 'staging' && r.head_sha === stagingSha && ['push', 'workflow_dispatch'].includes(r.event)); + if (run) + deployment = { runId: String(run.id), headSha: run.head_sha, status: run.status, conclusion: run.conclusion, + url: `https://github.com/${repository}/actions/runs/${run.id}`, updatedAt: run.updated_at }; + } + if (recoveryCheck) { + // This unique job belongs to the reviewed staging deployer. Recovery's GitHub App has + // Checks read, but no Actions access. Check suites independently bind the exact branch. + const { check_runs: checks } = await get(`commits/${stagingSha}/check-runs?per_page=100`); + for (const check of checks) { + if (check.name !== recoveryCheck || check.app?.slug !== 'github-actions' || check.head_sha !== stagingSha) + continue; + const suite = await get(`check-suites/${check.check_suite.id}`); + const run = new RegExp(`^https://github\\.com/${repository}/actions/runs/(\\d+)/job/\\d+$`).exec(check.details_url ?? ''); + if (!run || suite.head_branch !== 'staging' || suite.head_sha !== stagingSha || suite.app?.slug !== 'github-actions') + continue; + if (!deployment || deployment.runId === run[1]) + deployment = { runId: run[1], headSha: stagingSha, status: check.status, conclusion: check.conclusion, + url: `https://github.com/${repository}/actions/runs/${run[1]}`, updatedAt: check.completed_at ?? check.started_at ?? new Date().toISOString() }; + break; + } + } + // A moving source cannot produce an internally inconsistent snapshot. + const [current, currentProduction] = await Promise.all([get('branches/staging'), get('branches/master')]); + if (current.commit.sha !== stagingSha || currentProduction.commit.sha !== production.commit.sha) + throw new Error('Source moved during reconciliation. Retry on the next run.'); + return { repository, branch: 'staging', stagingSha, productionSha: production.commit.sha, observedAt: new Date().toISOString(), deployment, pullRequests, resolved }; +} + +// Appended to the compiled staging collector by scripts/sync-staging-intake.mjs. +const endpoint = 'https://factory-rover-81024286635.us-west1.run.app/github/staging-intake'; +async function sendObservation(payload) { + const request = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL); + request.searchParams.set('audience', 'https://factory.dealmachine.com/staging-intake'); + const identity = await fetch(request, { headers: { Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}` }, signal: AbortSignal.timeout(20000) }); + if (!identity.ok) throw new Error(`GitHub identity request failed (${identity.status}).`); + const { value } = await identity.json(); + if (!value) throw new Error('GitHub did not return an intake identity.'); + const body = JSON.stringify(payload); + if (Buffer.byteLength(body) > 500000) throw new Error('The staging observation exceeds the intake request limit.'); + const result = await fetch(endpoint, { method: 'POST', headers: { Authorization: `Bearer ${value}`, 'Content-Type': 'application/json' }, body, signal: AbortSignal.timeout(60000) }); + if (!result.ok) throw new Error(`Factory staging intake failed (${result.status}); the scheduled reconciliation will retry.`); +} +try { + if (process.env.FACTORY_INTAKE_MODE === 'reconcile') await sendObservation({ reconcile: true }); + else { + const observed = await collectStaging(process.env.GITHUB_REPOSITORY, process.env.GITHUB_TOKEN, process.env.FACTORY_DEPLOYMENT_WORKFLOW || null, process.env.FACTORY_DEPLOYMENT_CHECK || null); + for (let offset = 0; offset < observed.pullRequests.length || offset === 0; offset += 10) + await sendObservation({ ...observed, pullRequests: observed.pullRequests.slice(offset, offset + 10) }); + console.log(`Recorded ${observed.pullRequests.length} unpromoted staging PRs.`); + } +} catch (error) { console.error(error.message); process.exitCode = 1; } diff --git a/.github/workflows/factory-production-intake.yml b/.github/workflows/factory-production-intake.yml new file mode 100644 index 0000000..311c05b --- /dev/null +++ b/.github/workflows/factory-production-intake.yml @@ -0,0 +1,37 @@ +name: Track production builds in Factory +on: + workflow_run: + workflows: ["Publish CLI to npm"] + types: [requested, in_progress, completed] + branches: [master] + schedule: + - cron: '3,13,23,33,43,53 * * * *' + workflow_dispatch: +permissions: + contents: read + pull-requests: read + actions: read + id-token: write +concurrency: + group: factory-production-intake + cancel-in-progress: false +jobs: + intake: + if: github.ref == 'refs/heads/master' && vars.FACTORY_PRODUCTION_INTAKE_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + ref: master + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Record production build and deployment evidence + env: + GITHUB_TOKEN: ${{ github.token }} + FACTORY_PRODUCTION_WORKFLOW: "publish-npm.yml" + FACTORY_PRODUCTION_JOB: "Publish production CLI packages" + FACTORY_RUN_ID: ${{ github.event.workflow_run.id || '' }} + run: node .github/scripts/factory-production-intake.mjs diff --git a/.github/workflows/factory-release-intake.yml b/.github/workflows/factory-release-intake.yml new file mode 100644 index 0000000..8d18685 --- /dev/null +++ b/.github/workflows/factory-release-intake.yml @@ -0,0 +1,35 @@ +name: Track staging work in Factory + +on: + schedule: + - cron: '7,17,27,37,47,57 * * * *' + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + actions: read + id-token: write + +concurrency: + group: factory-staging-intake + cancel-in-progress: false + +jobs: + intake: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + ref: master + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Reconcile staging PRs and deployment receipts + env: + GITHUB_TOKEN: ${{ github.token }} + FACTORY_INTAKE_MODE: observe + FACTORY_DEPLOYMENT_WORKFLOW: '' + run: node .github/scripts/factory-release-intake.mjs diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 7d70b29..1aca15a 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -1,17 +1,13 @@ name: Publish CLI to npm on: + push: + branches: [master] workflow_dispatch: inputs: - version: - description: Exact version already committed on master - required: true + expected_sha: + description: Full reviewed source commit; reject a dispatch if the branch moved type: string - channel: - description: npm distribution tag - required: true - default: next - type: choice - options: [next, latest] + required: false permissions: contents: read concurrency: @@ -19,24 +15,44 @@ concurrency: cancel-in-progress: false jobs: publish: - if: github.ref == 'refs/heads/master' + name: Publish production CLI packages + if: github.ref == 'refs/heads/master' && vars.PRODUCTION_DEPLOY_ENABLED == 'true' runs-on: ubuntu-latest - environment: npm + timeout-minutes: 30 + environment: + name: npm + url: https://www.npmjs.com/package/dealmachine permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Verify the reviewed dispatch candidate + if: github.event_name == 'workflow_dispatch' && inputs.expected_sha != '' + env: + EXPECTED_SHA: ${{ inputs.expected_sha }} + shell: bash + run: | + [[ "$EXPECTED_SHA" =~ ^[a-f0-9]{40}$ ]] + test "$GITHUB_SHA" = "$EXPECTED_SHA" + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: actions/setup-node@v4 with: node-version: '24' registry-url: https://registry.npmjs.org - - name: Verify requested release - env: - RELEASE_VERSION: ${{ inputs.version }} - run: node --input-type=module -e "import fs from 'node:fs'; import assert from 'node:assert/strict'; assert.equal(JSON.parse(fs.readFileSync('package.json')).version, process.env.RELEASE_VERSION);" + - name: Verify trusted publishing tooling + run: node --input-type=module -e "import { execFileSync } from 'node:child_process'; import assert from 'node:assert/strict'; const v = execFileSync('npm', ['--version'], { encoding:'utf8' }).trim().split('.').map(Number); assert(v[0] > 11 || (v[0] === 11 && (v[1] > 5 || (v[1] === 5 && v[2] >= 1))), 'npm 11.5.1 or later is required for trusted publishing');" - run: npm ci - - name: Publish implementation and alias - env: - RELEASE_CHANNEL: ${{ inputs.channel }} - run: npm run release:publish -- --tag "$RELEASE_CHANNEL" + - name: Build, publish and verify implementation and alias + id: release + run: npm run release:auto:publish + - name: Retain release evidence and exact package archives + if: always() + uses: actions/upload-artifact@v4 + with: + name: cli-release-${{ github.sha }}-${{ github.run_attempt }} + path: artifacts/ + if-no-files-found: ignore + retention-days: 90 diff --git a/AGENTS.md b/AGENTS.md index e5210c0..a6c8eb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,4 +12,14 @@ Use the existing behavioral tests for changed Commands. Run `npm run check` and Set versions with `npm run release:version -- ` so the canonical package, alias and lockfile agree. A release requires an explicit channel and approved release scope. Publishing the CLI does not deploy the API, MCP server, docs site or Next app. +Production npm publication is disabled until the release owner explicitly enables the GitHub `PRODUCTION_DEPLOY_ENABLED` variable after configuring npm trusted publishing. While disabled, master pushes and manual dispatches skip the publishing job. After commissioning, merging into `master` automatically publishes both packages. The committed stable version is a release floor; the workflow selects the next available patch and records the source SHA in both packages. Treat a master merge as a release handoff once publication is enabled. Source delivery alone never authorizes changing the gate or npm publisher settings. See the release guide for retry, integrity and setup requirements. + Call CLI capabilities Commands, API capabilities Endpoints, and distributable agent instructions Playbooks. Write concrete copy and do not add em dashes to docs or comments. + +For new or changed Commands, assess the candidate package smoke and read-only live smoke described in docs/development.md. Update useful coverage in the same PR and identify uncovered behavior explicitly. Keep the corresponding Factory regression case and runner source version coordinated; unit tests alone do not prove the deployed service contract. + +## Test design + +Follow [Factory's testing policy](https://github.com/DealMachine/dealmachine-factory/blob/master/guides/testing.md). Start with end-to-end behavior and reuse its existing test owner. For this repository, prefer executable request, route, protocol or integration tests that exercise real owned code. Visible app journeys use Factory's E2E workflow before implementation when possible. Add a unit test only when it is fast, deterministic and protects a distinct useful contract, such as API validation, money, permissions, parsing or serialization. + +Avoid duplicate scenarios, mock-only behavior, private call counts and source checks that merely freeze an implementation. Keep independent public, security, storage and release contracts. A fix needs one regression that fails without the repair. During separately authorized cleanup, record the removed test's actual proof and its surviving owner, run the baseline, and obtain an independent preservation review. Ordinary feature work preserves existing coverage; this policy grants no unattended deletion authority. diff --git a/README.md b/README.md index f800178..c4f8d33 100644 --- a/README.md +++ b/README.md @@ -1359,3 +1359,9 @@ node dist/index.js mycommand --json ### Used By The **Playbook** at `packages/playbooks/playbook/` uses `dm` commands to execute property intelligence workflows. The CLI is the primary interface through which the Playbook interacts with DealMachine data. Agents can load the bundled Playbook directly with `dm agents playbook`. + +## Factory staging work tracking + +The `factory-release-intake.yml` workflow is maintained on master and reads merged PRs from staging. It reports source and, where registered, the exact staging deployment to Factory Releases, reusing Linear links or creating one configurable pilot tracking issue. Scheduled reconciliation retries missed events without duplicate issues. It does not deploy this repository, run QA or approve production. + +The collector is generated from Factory. See [automatic staging intake](https://github.com/DealMachine/dealmachine-factory/blob/master/guides/staging-intake.md) for configuration, source ownership, regeneration and recovery. Staging remains the branch being checked even though the reporting workflow lives on master. diff --git a/docs/commands.md b/docs/commands.md index d57c4bc..0745199 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -489,6 +489,7 @@ dm properties export --body '{"locations": [...]}' --mobile-only --json | `--mobile-only` | Only include wireless phone numbers | | `--landline-only` | Only include landline phone numbers | | `--scrub-dnc` | Exclude contacts on the Do Not Call registry | +| `--active-phones-only` | Exclude phone numbers with inactive or unknown reported activity | | `--json` | Output as JSON | --- @@ -1280,3 +1281,21 @@ node dist/index.js mycommand --json ### Used By The **Playbook** at `packages/playbooks/playbook/` uses `dm` commands to execute property intelligence workflows. The CLI is the primary interface through which the Playbook interacts with DealMachine data. Agents can load the bundled Playbook directly with `dm agents playbook`. + +Both `dm properties export` and `dm people export` accept `--active-phones-only`. It combines with existing phone-type and Do Not Call controls. + +## Bulk prospect lifecycle + +Archive or restore prospects by their underlying record IDs: + +```bash +dm prospects archive --ids prop_12345,prop_67890 --json +dm prospects reactivate --ids prop_12345,prop_67890 --json +dm prospects archive --ids person_777,person_778 --record-type person --no-cascade +``` + +Each call accepts up to 1,000 record IDs and uses `PATCH /v1/prospects`. +The summary counts changed prospects, prospects already in that lifecycle, and +records that are not prospects. Bulk archive ends mail and keeps list membership; +it closes open opportunities unless `--no-cascade` is supplied. A positional +`prospect_...` ID continues to use the single-prospect operation. diff --git a/docs/development.md b/docs/development.md index 9719cb0..97826cd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,4 +19,14 @@ The public agent plugin manifests and `skills/dealmachine` are retained in this From Factory, use `npm run setup:cli`, `npm run dev:cli`, `npm run cli:check` and `npm run cli:test:package`. Factory's `where cli` locates this checkout and `scripts cli` discovers its npm scripts. Read [releases](releases.md) before publishing. +Every `master` push runs the production npm workflow; its publishing job stays skipped until `PRODUCTION_DEPLOY_ENABLED` is explicitly set to `true` during authorized commissioning. `npm run release:auto:dry-run` reads the public registry, chooses the version, validates both package archives and simulates publication without changing the registry. It restores the local version files after the run. Published packages record their exact source SHA; the automatic version does not create a source commit or tag. + +## Service regression checks + +`test:package` is the candidate smoke suite. It tests both installed npm names, command discovery, module import, Playbook installation, list request behavior and the live-smoke protocol against a local HTTP fixture, including credit-free estimates and authentication rejection. It runs in PR CI and needs no API key. + +`npm run smoke:live` requires explicit `DM_API_URL` ending in `/v1` and `DM_API_KEY` for an approved QA workspace. It builds this candidate and runs real Commands for account identity, field discovery and a credit-free property estimate. It isolates configuration and the credential store, removes its temporary files and does not read your saved login. Missing configuration exits 2; a failed request or response assertion exits 1. It does not prove writes, paid data, OAuth consent, every Command or the published npm version. + +Factory registers these as `cli:test:package` and `cli:smoke:live`. Keep executable checks here and the shared regression case, reviewed source version, runner delivery and private credentials in Factory. When adding or changing a Command, assess both suites and update the relevant public behavior check alongside its acceptance criteria. A local fixture pass never substitutes for an authenticated live result. + `npm run smoke:live:coverage` adds version and filter checks plus request-header and read-only relay assertions. It requires the same approved QA target and key, rejects non-HTTPS remote targets and non-plain `/v1` bases, and explicitly disables the OS credential store in child processes. Its built-CLI integration test uses a synthetic local API and runs in routine CI on Ubuntu with Node 22 and 24. It checks missing credentials, request authentication, unchanged caller settings and temporary-directory cleanup, including paths with spaces and `#`. The isolation module is loaded by file URL so Windows drive letters and URL-significant path characters work. Factory runs the registered live smoke against staging with its approved QA workspace reserved for the run. Full macOS and Windows verification is performed separately by the authorized local coding session or an approved platform runner; routine CI uses no live credentials. diff --git a/docs/releases.md b/docs/releases.md index a97dbf9..770fa95 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,47 +1,76 @@ # npm releases -`@dealmachine/cli` is the implementation and `dealmachine` is the short install alias. Both are public on npmjs.org. They must have the same version; the alias pins the exact implementation version. The version command also updates `src/version.ts`, and packed-install checks verify the reported version against the manifest. Agent plugin and hosted MCP manifest versions follow their own release lifecycle. +`@dealmachine/cli` is the implementation and `dealmachine` is the short install alias. Both are public on npmjs.org. They have the same version; the alias pins the exact implementation version. The runtime version in `src/version.ts` is updated with the package manifests, and the packed consumer check verifies it. Agent plugin and hosted MCP manifest versions follow their own release lifecycle. -The extraction candidate is `0.4.0-rc.1`; `0.3.0` remains the published baseline until a new release runs. Review the newly moved Commands against the intended API environment before promoting a stable release. +## Automatic production publication -```sh -npm run release:version -- 0.4.0-rc.1 -npm run release:dry-run -- --tag next -``` +Every push to this public repository's `master` runs `publish-npm.yml`, named **Publish CLI to npm**. Its **Publish production CLI packages** job requires the GitHub Actions variable `PRODUCTION_DEPLOY_ENABLED` to equal `true`; an unset or false gate skips publication for both master pushes and manual dispatches. After authorized commissioning, the job builds, checks, installs and publishes both packages to `latest`, then verifies their source SHA, integrity and distribution tags through the public registry. Factory observes that job for production build and release tracking. A skipped job or failed or incomplete pair is not a successful deployment. Source merges, publication and any subsequent production regression are separate evidence. + +The committed stable version, initially `0.4.0`, sets the minimum release version. When that version or a later stable version already exists in either package, the workflow selects the next patch after the highest stable version. For example, a committed `0.4.0` with published `0.4.10` produces `0.4.11`. Set and commit a higher minor or major version with `release:version` when the change warrants it. Automatic publishing refuses a prerelease baseline. + +The job updates the version only in its temporary build checkout. Both npm manifests contain `dealmachineRelease.sourceSha`; the workflow does not create a source commit or Git tag. The source version is a floor, so it can differ from an installed package's actual runtime version. The receipt and published metadata identify the exact source and package versions. + +Releases run serially. A job whose source is no longer current `master` stops before publication. Re-running the current commit discovers any package already published for that SHA, rebuilds and compares its integrity, then publishes only the missing package. A conflicting source, changed bytes, multiple versions for one source, or a newer published stable version stops the retry. The workflow never overwrites immutable npm versions or silently moves `latest` backward. + +The workflow retains both archives and `release.json` under the `cli-release--` GitHub artifact for 90 days, including evidence available after a failure. The receipt has source SHA, workflow run ID, version, channel, integrity digests and a `verified` result. A successful job requires verification of both packages. The run summary links to both exact npm versions. + +## One-time access setup + +For **both** npm package settings, configure a GitHub trusted publisher with these exact values: -The version command updates both manifests, the runtime version and the canonical lockfile without creating a Git tag. The dry run executes checks, installs both packed archives in a clean consumer, and calls `npm publish --dry-run`. It does not publish. `release:pack` performs the same validation and leaves archives plus a `release.json` integrity record in ignored `artifacts//`. +| Setting | Value | +| --- | --- | +| Organization | `DealMachine` | +| Repository | `dealmachine-cli` | +| Workflow filename | `publish-npm.yml` | +| Environment | `npm` | +| Allowed action | Direct publication with `npm publish` | -For a local approved release, commit the reviewed changes and use: +Create the GitHub `npm` environment and restrict deployment branches to `master`. For unattended publication on a reviewed master merge, this environment must not require a second manual review. Repository branch review and checks remain the source approval gate. Do not weaken an existing environment rule without the release owner's approval. + +Keep `PRODUCTION_DEPLOY_ENABLED` unset or false while landing source changes. After both trusted publishers and the environment are verified, the release owner must explicitly authorize setting this repository variable to `true`. That commissioning action enables future master pushes and manual recovery dispatches to publish. Neither merging this workflow nor setting up source delivery authorizes enabling the gate. Existing owner release commands retain their explicit release-authorization requirements. + +The job uses GitHub-hosted Ubuntu, Node 24, npm 11.5.1 or later, and `id-token: write`. No npm token belongs in GitHub secrets or chat. Trusted publication from this public repository supplies npm provenance. npm package-owner access is needed for the publisher setup, which cannot be verified from public package metadata. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). + +After authorized setup, gate activation and merge, recover the current `master` release with: ```sh -npm run release:publish -- --tag next +gh workflow run publish-npm.yml --repo DealMachine/dealmachine-cli --ref master ``` -Publishing requires a clean checkout, authenticated npm access and an explicit `next` or `latest` tag. Prereleases cannot use `latest`. The implementation is published before the alias. Both uploads are independent registry operations: if the second fails, verify the first package's published integrity against `artifacts//release.json`, then publish only the matching alias archive with `npm publish artifacts//dealmachine-.tgz --access public --registry https://registry.npmjs.org --tag next`. Do not rebuild or replace an already published version. +Do not dispatch an older run to roll back a release. If registry access is missing, the run fails and preserves its built artifacts; configure the access above and rerun the current master workflow. -## GitHub publishing +## Validate without publication -The manual `publish-npm.yml` workflow runs only from `master`, checks the requested version, validates the packages and publishes through npm trusted publishing. It uses the GitHub environment `npm` and an OIDC token. It is not triggered by a normal commit or merge. +```sh +npm run release:auto:dry-run +``` -For **both** npm package settings, configure a GitHub trusted publisher with organization `DealMachine`, repository `dealmachine-cli`, workflow filename `publish-npm.yml`, environment `npm`, and permission to publish. Configure the GitHub `npm` environment with the team's release reviewers. Do not add tokens to the repository. npm requires CLI 11.5.1+ and Node 22.14+; the workflow uses Node 24. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). +This reads public registry metadata, selects the candidate version, runs `check` and `test:package`, and calls `npm publish --dry-run` on each unpublished archive. It restores local version files, publishes nothing and leaves artifacts under ignored `artifacts//`. It does not prove npm publishing permission. -After the source PR is reviewed and merged, an authorized maintainer can dispatch: +For an explicit local prerelease or manually selected version: ```sh -gh workflow run publish-npm.yml --repo DealMachine/dealmachine-cli --ref master -f version=0.4.0-rc.1 -f channel=next +npm run release:version -- 0.5.0-rc.1 +npm run release:dry-run -- --tag next +npm run release:pack +# Only with authorized release scope, a clean committed checkout and npm access: +npm run release:publish -- --tag next ``` -To publish a stable version, set a new stable version, rerun validation, merge the reviewed change and dispatch with `channel=latest`. Removing `-rc` creates a new artifact; moving a tag does not rename a version. Never overwrite an existing npm version. +The manual commands retain an explicit `next` or `latest` channel; prereleases cannot use `latest`. Do not merge a prerelease version into master because its automatic production job requires a stable version. Do not run manual publication concurrently with the automatic workflow. ## Verify and recover ```sh npm view @dealmachine/cli dist-tags --json npm view dealmachine dist-tags --json -npx --yes --package=dealmachine@0.4.0-rc.1 dm --version -npx --yes --package=dealmachine@0.4.0-rc.1 dm agents playbook +npx --yes --package=dealmachine@0.4.0 dm --version +npx --yes --package=dealmachine@0.4.0 dm agents playbook ``` -Use exact versions for release verification. `eval:cold-start:published` checks the existing default npm channel; it does not select a prerelease automatically. `eval:cold-start:deployed` checks the hosted docs surface and can fail independently of a valid CLI package. +Replace `0.4.0` with the exact version from the release receipt. `eval:cold-start:published` checks the default npm channel; `eval:cold-start:deployed` checks the hosted docs independently. A package release does not deploy API, MCP, Next or the documentation site. + +The two npm uploads are separate operations. If the alias upload fails, the implementation may already be available; keep the workflow failed until both are verified. Retry the current master workflow to reuse the same source version and verify its bytes. If the source has moved, its next release can publish a new pair, while the partial version remains available for diagnosis. If bytes differ on a retry, retain the original archive and receipt for an owner; do not bypass the integrity check. -Rollback means moving the affected distribution tags for both packages back to the previously verified version, then verifying new installs. Existing installed versions do not change automatically. Publishing this client does not deploy the API, MCP, Next application, or docs site. +An authorized rollback moves both distribution tags to the previous verified version, then verifies fresh installs. Existing installed versions do not change. The automated publisher never performs that rollback. After a rollback, a new reviewed master commit receives a higher version, even though `latest` points backward. Moving a tag does not rename an immutable package version. diff --git a/npm-alias/package.json b/npm-alias/package.json index 5865b95..9acfaa7 100644 --- a/npm-alias/package.json +++ b/npm-alias/package.json @@ -1,6 +1,6 @@ { "name": "dealmachine", - "version": "0.4.0-rc.1", + "version": "0.4.1-rc.2", "description": "Short install alias for the DealMachine property intelligence CLI", "author": "DealMachine", "license": "MIT", @@ -23,7 +23,7 @@ "LICENSE" ], "dependencies": { - "@dealmachine/cli": "0.4.0-rc.1" + "@dealmachine/cli": "0.4.1-rc.2" }, "engines": { "node": ">=18" diff --git a/package-lock.json b/package-lock.json index dfad486..c4223ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dealmachine/cli", - "version": "0.4.0-rc.1", + "version": "0.4.1-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dealmachine/cli", - "version": "0.4.0-rc.1", + "version": "0.4.1-rc.2", "license": "MIT", "dependencies": { "chalk": "^5.3.0", diff --git a/package.json b/package.json index bdac384..f230193 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dealmachine/cli", - "version": "0.4.0-rc.1", + "version": "0.4.1-rc.2", "description": "DealMachine CLI for property intelligence, people lookup, and enrichment through the public API", "author": "DealMachine", "license": "MIT", @@ -39,7 +39,10 @@ "release:pack": "node scripts/release.mjs pack", "release:dry-run": "node scripts/release.mjs dry-run", "release:publish": "node scripts/release.mjs publish", - "prepack": "npm run build && npm run check:artifact" + "release:auto:dry-run": "node scripts/release-auto.mjs dry-run", + "release:auto:publish": "node scripts/release-auto.mjs publish", + "prepack": "npm run build && npm run check:artifact", + "smoke:live": "npm run build && node scripts/smoke-live.mjs" }, "dependencies": { "chalk": "^5.3.0", diff --git a/scripts/release-auto.mjs b/scripts/release-auto.mjs new file mode 100644 index 0000000..3cd0842 --- /dev/null +++ b/scripts/release-auto.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout } from 'node:timers/promises'; +import { planRelease, releasePackages, verifyPublishedPackage } from './release-plan.mjs'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const [mode, ...extra] = process.argv.slice(2); +assert(['dry-run', 'publish'].includes(mode) && !extra.length, 'Use release:auto:dry-run or release:auto:publish.'); +const execute = (command, args, options = {}) => execFileSync(command, args, { cwd: root, stdio: 'inherit', ...options }); +const git = (...args) => execute('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); +const sourceSha = git('rev-parse', 'HEAD'); +const assertCurrentMaster = () => { + assert.equal(git('ls-remote', 'origin', 'refs/heads/master').split(/\s/)[0], sourceSha, 'Source is no longer current master. Let the newest master run publish.'); +}; +if (mode === 'publish') { + assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Automatic publishing runs only in GitHub Actions; use release:publish for an authorized local release.'); + assert.equal(process.env.GITHUB_REPOSITORY, 'DealMachine/dealmachine-cli'); + assert.equal(process.env.GITHUB_REF, 'refs/heads/master'); + assert.equal(process.env.GITHUB_SHA, sourceSha); + assert(['push', 'workflow_dispatch'].includes(process.env.GITHUB_EVENT_NAME), 'Only master push and recovery dispatch may publish.'); + assert.equal(git('status', '--porcelain'), '', 'Start from the clean committed release source.'); + assertCurrentMaster(); +} + +async function registry(name) { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, { + headers: { accept: 'application/json', 'cache-control': 'no-cache' }, + redirect: 'error', signal: AbortSignal.timeout(30_000), + }); + assert(response.ok, `Cannot read ${name} registry metadata (${response.status}). No release decision was made.`); + const metadata = await response.json(); + assert(metadata.name === name && metadata.versions && typeof metadata.versions === 'object', `Invalid registry metadata for ${name}.`); + return metadata; +} +const registries = Object.fromEntries(await Promise.all(releasePackages.map(async name => [name, await registry(name)]))); +const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); +const plan = planRelease(manifest.version, sourceSha, registries); +const changedFiles = ['package.json', 'package-lock.json', 'npm-alias/package.json', 'src/version.ts']; +const originals = new Map(changedFiles.map(name => [name, readFileSync(resolve(root, name))])); +console.log(`${mode === 'dry-run' ? 'Would publish' : 'Publishing'} both CLI packages as ${plan.version} from ${sourceSha}.`); +try { + execute(process.execPath, ['scripts/release-version.mjs', plan.version]); + for (const name of ['package.json', 'npm-alias/package.json']) { + const path = resolve(root, name); + const data = JSON.parse(readFileSync(path, 'utf8')); + data.dealmachineRelease = { sourceSha }; + writeFileSync(path, JSON.stringify(data, null, 2) + '\n'); + } + // This runs the owner's complete checks and clean consumer install before either upload. + execute(process.execPath, ['scripts/release.mjs', 'pack', '--tag', plan.tag]); + const output = resolve(root, 'artifacts', plan.version); + const record = JSON.parse(readFileSync(resolve(output, 'release.json'), 'utf8')); + Object.assign(record, plan, { repository: 'DealMachine/dealmachine-cli', workflowRunId: process.env.GITHUB_RUN_ID ?? null, verified: false }); + writeFileSync(resolve(output, 'release.json'), JSON.stringify(record, null, 2) + '\n'); + // Check both existing packages before writing either one; a mixed pair must stop here. + for (const artifact of record.artifacts) { + if (registries[artifact.name].versions?.[plan.version]) verifyPublishedPackage(artifact, registries[artifact.name], plan, { requireTag: true }); + } + if (mode === 'publish') assertCurrentMaster(); + for (const artifact of record.artifacts) { + if (registries[artifact.name].versions?.[plan.version]) { + console.log(`Verified existing ${artifact.name}@${plan.version}; resuming without republishing it.`); + } else { + execute('npm', ['publish', resolve(output, artifact.filename), '--ignore-scripts', '--access', 'public', '--registry', 'https://registry.npmjs.org', '--tag', plan.tag, ...(mode === 'dry-run' ? ['--dry-run'] : [])]); + } + if (mode === 'publish') { + // Public registry replicas may briefly lag the successful publish response. + let metadata; + for (let attempt = 0; attempt < 6; attempt++) { + metadata = await registry(artifact.name); + if (metadata.versions?.[plan.version] && metadata['dist-tags']?.[plan.tag] === plan.version) break; + if (attempt < 5) await setTimeout(2_000); + } + verifyPublishedPackage(artifact, metadata, plan, { requireTag: true }); + } + } + if (mode === 'publish') { + for (const artifact of record.artifacts) verifyPublishedPackage(artifact, await registry(artifact.name), plan, { requireTag: true }); + record.verified = true; + record.verifiedAt = new Date().toISOString(); + writeFileSync(resolve(output, 'release.json'), JSON.stringify(record, null, 2) + '\n'); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `version=${plan.version}\n`); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Published and verified both CLI packages at **${plan.version}** (latest).\n\nSource: \`${sourceSha}\`\n\n- [@dealmachine/cli](https://www.npmjs.com/package/@dealmachine/cli/v/${plan.version})\n- [dealmachine](https://www.npmjs.com/package/dealmachine/v/${plan.version})\n\nThe release artifact includes both package integrity digests.\n`); + } +} finally { + for (const [name, contents] of originals) writeFileSync(resolve(root, name), contents); +} diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs new file mode 100644 index 0000000..9f4cb8b --- /dev/null +++ b/scripts/release-plan.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; + +export const releasePackages = ['@dealmachine/cli', 'dealmachine']; +const stable = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const compare = (left, right) => { + const a = left.split('.').map(Number); + const b = right.split('.').map(Number); + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +}; + +// Registry metadata makes a retry recover the version already published for this commit. +export function planRelease(baseVersion, sourceSha, registries) { + assert(stable.test(baseVersion), 'Automatic production releases require a stable committed version.'); + assert(/^[a-f0-9]{40}$/.test(sourceSha), 'A full Git source SHA is required.'); + const versions = releasePackages.flatMap(name => Object.entries(registries[name].versions ?? {})); + const matching = [...new Set(versions.filter(([, value]) => value.dealmachineRelease?.sourceSha === sourceSha).map(([version]) => version))]; + assert(matching.length <= 1, 'This source has multiple published versions. An owner must reconcile the registry.'); + const stableVersions = versions.map(([version]) => version).filter(version => stable.test(version)).sort(compare); + const highest = stableVersions.at(-1); + let version = matching[0]; + if (version) { + assert(stable.test(version), 'This source was published as a prerelease; production needs a new reviewed commit.'); + assert(!highest || compare(version, highest) >= 0, 'A newer version already exists. Refusing to move latest backward.'); + for (const name of releasePackages) { + const existing = registries[name].versions?.[version]; + assert(!existing || existing.dealmachineRelease?.sourceSha === sourceSha, `${name}@${version} belongs to another source. Refusing a mixed release.`); + } + } else if (!highest || compare(baseVersion, highest) > 0) { + version = baseVersion; + } else { + const [major, minor, patch] = highest.split('.').map(Number); + version = `${major}.${minor}.${patch + 1}`; + } + return { version, tag: 'latest', sourceSha }; +} + +export function verifyPublishedPackage(artifact, metadata, plan, { requireTag = false } = {}) { + const published = metadata.versions?.[plan.version]; + assert(published, `${artifact.name}@${plan.version} is missing from npm.`); + assert.equal(published.dealmachineRelease?.sourceSha, plan.sourceSha, `${artifact.name} source does not match this release.`); + assert.equal(published.dist?.integrity, artifact.integrity, `${artifact.name} published bytes differ. Never replace an existing version.`); + if (artifact.name === 'dealmachine') { + assert.equal(published.dependencies?.['@dealmachine/cli'], plan.version, 'The alias must pin this exact implementation version.'); + } + if (requireTag) assert.equal(metadata['dist-tags']?.[plan.tag], plan.version, `${artifact.name} latest is not this version. An owner must reconcile the channel.`); + return published; +} diff --git a/scripts/smoke-live.mjs b/scripts/smoke-live.mjs new file mode 100644 index 0000000..cd51309 --- /dev/null +++ b/scripts/smoke-live.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// An explicit target and QA key are required. Never fall back to a saved login. +const apiKey = process.env.DM_API_KEY; +const target = process.env.DM_API_URL; +if (!apiKey || !target) { + console.error('BLOCKED: set DM_API_URL and DM_API_KEY for an approved QA workspace.'); + process.exit(2); +} +const url = new URL(target); +assert.equal(url.pathname.replace(/\/$/, ''), '/v1', 'DM_API_URL must end in /v1'); +assert.ok(!url.username && !url.password && !url.search && !url.hash, 'Use a plain API base URL'); +assert.ok(url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)), 'Remote targets require HTTPS'); +const temporary = await mkdtemp(join(tmpdir(), 'dm-live-smoke-')); +const root = fileURLToPath(new URL('..', import.meta.url)); +const execute = promisify(execFile); +const environment = { ...process.env, DM_API_URL: target, DM_API_KEY: apiKey, CI: 'true', NO_COLOR: '1' }; +let step = 'isolated credential setup'; +try { + const isolatedHome = join(temporary, 'home'); + await mkdir(isolatedHome, { mode: 0o700 }); + const isolation = join(temporary, 'isolation.mjs'); + await writeFile(isolation, [ + "import os from 'node:os';", + "import childProcess from 'node:child_process';", + "import { syncBuiltinESMExports } from 'node:module';", + `os.homedir = () => ${JSON.stringify(isolatedHome)};`, + "childProcess.execFileSync = () => { throw new Error('Personal credential store disabled during smoke'); };", + 'syncBuiltinESMExports();', + ].join('\n'), { mode: 0o600 }); + await execute(process.execPath, ['--import', isolation, '--input-type=module', '--eval', + `const { writeConfig } = await import(${JSON.stringify(new URL('../dist/lib/config.js', import.meta.url).href)}); writeConfig({apiKey: process.env.DM_API_KEY, keyId:'qa-smoke', organizationId:0, organizationName:'QA smoke', organizationSlug:'qa-smoke'});`, + ], { cwd: temporary, env: environment, timeout: 10000 }); + const command = async args => { + const { stdout } = await execute(process.execPath, ['--import', isolation, join(root, 'dist/index.js'), ...args, '--json', '--quiet'], { + cwd: temporary, env: environment, timeout: 45000, maxBuffer: 2 * 1024 * 1024, + }); + return JSON.parse(stdout); + }; + step = 'account authentication'; + const account = await command(['account']); + assert.ok(account.data?.organization?.id, 'Account must identify a QA organization'); + assert.ok(['api_key', 'oauth'].includes(account.data?.user?.authType), 'Account must identify authentication type'); + step = 'field discovery'; + const fields = await command(['fields', '--source-type', 'properties', '--per-page', '1']); + assert.ok(Array.isArray(fields.data) && fields.data.length > 0, 'Fields must contain a discoverable field'); + assert.equal(typeof fields.data[0].field_id, 'string', 'Fields must expose a field ID'); + step = 'credit-free search estimate'; + const estimate = await command(['properties', 'search', '--estimate-cost', '--body', JSON.stringify({ + locations: [{ type: 'zip_code', code: '46203' }], pagination: { page: 1, per_page: 1 }, + })]); + assert.ok(Number.isFinite(estimate.totals?.properties) && estimate.totals.properties >= 0, 'Estimate must include property count'); + assert.ok(Number.isFinite(estimate.estimated_credits?.this_page) && estimate.estimated_credits.this_page >= 0, 'Estimate must include credit cost'); + console.log('PASS: CLI account, field discovery and credit-free search estimate. No records written or purchased.'); +} catch { + // Child process errors can contain API data or credentials. Emit only the step. + console.error(`FAIL: CLI live smoke at ${step}. Inspect the private API request logs for the selected QA target.`); + process.exitCode = 1; +} finally { + await rm(temporary, { recursive: true, force: true }); +} diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 7cef589..e398553 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -61,9 +61,14 @@ try { const server = createServer(async (request, response) => { const chunks = []; for await (const chunk of request) chunks.push(chunk); - requests.push({ method: request.method, path: request.url, body: JSON.parse(Buffer.concat(chunks).toString()), headers: request.headers }); - response.writeHead(200, { 'Content-Type': 'application/json' }); - response.end(JSON.stringify({ data: {} })); + const body = Buffer.concat(chunks).toString(); + requests.push({ method: request.method, path: request.url, body: body ? JSON.parse(body) : null, headers: request.headers }); + const estimate = { totals: { properties: 12, people: 0 }, estimated_credits: { this_page: 1, total_all_pages: 12 } }; + const account = { data: { organization: { id: 123, name: 'Package fixture' }, user: { id: null, authType: 'api_key' } } }; + const fields = { data: [{ field_id: 'property_address', name: 'Address' }] }; + const result = request.url === '/v1/account' ? account : request.url.startsWith('/v1/fields') ? fields : request.url === '/v1/properties/search' ? estimate : { data: {} }; + response.writeHead(request.headers.authorization === 'Bearer rejected-fixture' ? 401 : 200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(result)); }); server.listen(0, '127.0.0.1'); await once(server, 'listening'); @@ -90,6 +95,22 @@ try { assert.equal(request.headers['x-dealmachine-source'], 'cli'); } } + // Exercise the same live-smoke entry point with the packed consumer's + // protocol fixture. This proves command wiring, not deployed API behavior. + const liveEnvironment = { ...process.env, DM_API_URL: `http://127.0.0.1:${server.address().port}/v1`, DM_API_KEY: 'dm-package-test' }; + const smoke = await execute(process.execPath, [join(root, 'scripts/smoke-live.mjs')], { cwd: temporary, env: liveEnvironment, timeout: 60000 }); + assert.match(smoke.stdout, /PASS: CLI account/); + assert.deepEqual(requests.map(request => [request.method, request.path]), [ + ['GET', '/v1/account'], ['GET', '/v1/fields?source_type=properties&per_page=1'], ['POST', '/v1/properties/search'], + ]); + assert.equal(requests.at(-1).body.estimate_cost, true, 'The live smoke must never purchase search results'); + assert.equal(requests.at(-1).body.pagination.per_page, 1); + for (const request of requests) assert.equal(request.headers.authorization, 'Bearer dm-package-test'); + requests.length = 0; + await assert.rejects(execute(process.execPath, [join(root, 'scripts/smoke-live.mjs')], { + cwd: temporary, env: { ...liveEnvironment, DM_API_KEY: 'rejected-fixture' }, timeout: 60000, + }), error => error.code === 1 && /account authentication/.test(error.stderr) && !error.stderr.includes('rejected-fixture')); + assert.equal(requests.length, 1, 'Authentication rejection must stop before further API requests'); } finally { await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); } diff --git a/src/commands/lists.ts b/src/commands/lists.ts index 2ba2178..9f71018 100644 --- a/src/commands/lists.ts +++ b/src/commands/lists.ts @@ -314,7 +314,7 @@ export async function listsBuild( 'Progress': `${l.progress}%`, }); console.log(); - console.log(chalk.dim(' Poll with: dm lists get ' + listId)); + console.log(chalk.dim(' Poll with: dm lists get ' + l.list_id)); console.log(); } @@ -366,7 +366,7 @@ export async function listsImport( 'Progress': `${l.progress}%`, }); console.log(); - console.log(chalk.dim(' Poll with: dm lists get ' + listId)); + console.log(chalk.dim(' Poll with: dm lists get ' + l.list_id)); console.log(); } diff --git a/src/commands/people.ts b/src/commands/people.ts index c72ffe0..1e52f29 100644 --- a/src/commands/people.ts +++ b/src/commands/people.ts @@ -353,6 +353,7 @@ export async function peopleExport( mobileOnly?: boolean; landlineOnly?: boolean; scrubDnc?: boolean; + activePhonesOnly?: boolean; } & SearchProtocolCliOptions ): Promise { const requestBody = await parseRequestBody(options); @@ -364,6 +365,7 @@ export async function peopleExport( if (options.mobileOnly) requestBody.mobile_only = true; if (options.landlineOnly) requestBody.landline_only = true; if (options.scrubDnc) requestBody.scrub_dnc = true; + if (options.activePhonesOnly) requestBody.active_phones_only = true; const spinner = createSpinner('Exporting people (this may take 30-60 seconds)...').start(); const data = await apiRequest('/people/export', { diff --git a/src/commands/properties.ts b/src/commands/properties.ts index 1b6ac8a..42b68d9 100644 --- a/src/commands/properties.ts +++ b/src/commands/properties.ts @@ -329,6 +329,7 @@ export async function propertiesExport( mobileOnly?: boolean; landlineOnly?: boolean; scrubDnc?: boolean; + activePhonesOnly?: boolean; contactAudience?: string; anchor?: string; } & SearchProtocolCliOptions @@ -349,6 +350,7 @@ export async function propertiesExport( if (options.mobileOnly) requestBody.mobile_only = true; if (options.landlineOnly) requestBody.landline_only = true; if (options.scrubDnc) requestBody.scrub_dnc = true; + if (options.activePhonesOnly) requestBody.active_phones_only = true; const spinner = createSpinner('Exporting properties (this may take 30-60 seconds)...').start(); const data = await apiRequest('/properties/export', { diff --git a/src/commands/prospects.ts b/src/commands/prospects.ts index 5893812..d94c4a1 100644 --- a/src/commands/prospects.ts +++ b/src/commands/prospects.ts @@ -26,6 +26,8 @@ type Prospect = { record_id: string; lifecycle: string; favorite: boolean; + /** Read only: hot, warm, cold, follow_up, do_not_contact, or null. */ + status?: string | null; source: string; record: { address?: string | null; @@ -56,16 +58,29 @@ function recordLabel(prospect: Prospect): string { return place ? `${main} (${place})` : String(main); } +const STATUS_LABELS: Record = { + hot: 'Hot', + warm: 'Warm', + cold: 'Cold', + follow_up: 'Follow up', + do_not_contact: 'Do Not Contact', +}; + +function statusLabel(prospect: Prospect): string { + return prospect.status ? (STATUS_LABELS[prospect.status] ?? prospect.status) : 'None'; +} + function printProspectRows(prospects: Prospect[]) { const rows = prospects.map((p) => ({ id: p.id, record: truncate(recordLabel(p), 44), lifecycle: p.lifecycle, fav: p.favorite ? '★' : '', + status: statusLabel(p), tags: truncate(p.tags.map((tag) => tag.name).join(', ') || '—', 24), added: formatDate(p.created_at), })); - printTable(rows, ['id', 'record', 'lifecycle', 'fav', 'tags', 'added']); + printTable(rows, ['id', 'record', 'lifecycle', 'fav', 'status', 'tags', 'added']); } function printPagination(p: Pagination) { @@ -138,6 +153,7 @@ export async function prospectsGet(id: string, options: { json?: boolean }): Pro Type: p.record_type, Lifecycle: p.lifecycle, Favorite: p.favorite ? 'Yes' : 'No', + Status: statusLabel(p), Source: p.source, Tags: p.tags.map((tag) => `${tag.name} (${tag.id})`).join(', ') || '—', Notes: String(p.note_count ?? 0), @@ -173,6 +189,7 @@ export async function prospectsGetByRecord(options: { Type: p.record_type, Lifecycle: p.lifecycle, Favorite: p.favorite ? 'Yes' : 'No', + Status: statusLabel(p), Source: p.source, Tags: p.tags.map((tag) => `${tag.name} (${tag.id})`).join(', ') || '—', Added: formatDate(p.created_at), @@ -238,10 +255,74 @@ async function setLifecycle( console.log(); } -export async function prospectsArchive( - id: string, - options: { json?: boolean; noCascade?: boolean } +type BulkMoveOptions = { + /** Comma-separated record IDs (prop_123, person_456, or bare numbers), up to 1,000. */ + ids?: string; + recordType?: string; + json?: boolean; + noCascade?: boolean; +}; + +/** + * The bulk form of a lifecycle move: `PATCH /prospects` over record IDs, the + * same call the web selection action bar makes. Records that are not + * prospects, and prospects already in the lifecycle, are skipped and counted. + */ +async function moveProspectsBulk( + lifecycle: 'active' | 'archived', + options: BulkMoveOptions & { ids: string } ) { + const recordIds = splitIds(options.ids); + if (recordIds.length === 0) { + console.error(chalk.red('Error: --ids needs at least one record ID')); + process.exit(1); + } + const spinner = createSpinner( + lifecycle === 'archived' ? 'Archiving prospects...' : 'Restoring prospects...' + ).start(); + const data = await apiRequest<{ + data: { + lifecycle: string; + changed: number; + already_in_lifecycle: number; + not_prospects: number; + changed_record_ids: string[]; + }; + }>('/prospects', { + method: 'PATCH', + body: { + record_ids: recordIds, + lifecycle, + ...(options.recordType && { record_type: options.recordType }), + ...(options.noCascade ? { cascade: false } : {}), + }, + }); + spinner.stop(); + if (options.json) { + printJson(data); + return; + } + const d = data.data; + printHeader(lifecycle === 'archived' ? 'Prospects Archived' : 'Prospects Restored'); + printKeyValue({ + [lifecycle === 'archived' ? 'Archived' : 'Restored']: String(d.changed), + [lifecycle === 'archived' ? 'Already archived' : 'Already active']: String( + d.already_in_lifecycle + ), + 'Not prospects': String(d.not_prospects), + }); + console.log(); +} + +export async function prospectsArchive(id: string | undefined, options: BulkMoveOptions) { + if (options.ids) { + await moveProspectsBulk('archived', { ...options, ids: options.ids }); + return; + } + if (!id) { + console.error(chalk.red('Error: pass a prospect ID, or --ids with record IDs to archive in bulk')); + process.exit(1); + } await setLifecycle(id, 'archived', options); } @@ -263,7 +344,15 @@ export async function prospectsRemove( console.log(); } -export async function prospectsReactivate(id: string, options: { json?: boolean }) { +export async function prospectsReactivate(id: string | undefined, options: BulkMoveOptions) { + if (options.ids) { + await moveProspectsBulk('active', { ...options, ids: options.ids }); + return; + } + if (!id) { + console.error(chalk.red('Error: pass a prospect ID, or --ids with record IDs to restore in bulk')); + process.exit(1); + } await setLifecycle(id, 'active', options); } diff --git a/src/index.ts b/src/index.ts index 69cac75..52592c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -774,6 +774,10 @@ propertiesCmd .option('--mobile-only', 'Only include wireless phone numbers') .option('--landline-only', 'Only include landline phone numbers') .option('--scrub-dnc', 'Exclude contacts on the Do Not Call registry') + .option( + '--active-phones-only', + 'Only include phone numbers with active reported phone activity (inactive/unknown excluded)' + ) .option('--json', 'Output as JSON') .addHelpText( 'after', @@ -1164,6 +1168,10 @@ peopleCmd .option('--mobile-only', 'Only include wireless phone numbers') .option('--landline-only', 'Only include landline phone numbers') .option('--scrub-dnc', 'Exclude contacts on the Do Not Call registry') + .option( + '--active-phones-only', + 'Only include phone numbers with active reported phone activity (inactive/unknown excluded)' + ) .option('--json', 'Output as JSON') .addHelpText( 'after', @@ -1634,7 +1642,7 @@ prospectsCmd .option('--source ', 'Filter by source, e.g. driving, import, api, cli, or mcp') .option('--favorites', 'Only starred prospects') .option('--list ', 'Only members of this list') - .option('--tag ', 'Only prospects with this tag, e.g. tag_5') + .option('--tag ', 'Only prospects with this tag, e.g. tag_31') .option('--search ', 'Address words for properties; name or city for people') .option('--sort ', 'newest (default) or oldest') .option('-p, --page ', 'Page number') @@ -1646,7 +1654,7 @@ prospectsCmd Examples: dm prospects list dm prospects list --source driving - dm prospects list --lifecycle opportunity --tag tag_1 + dm prospects list --lifecycle opportunity --tag tag_31 dm prospects list --record-type person --search "austin" --json` ) .action(async (options) => { @@ -1691,12 +1699,29 @@ Examples: }); prospectsCmd - .command('archive ') - .description('Archive a prospect (ends its mail and keeps its lists and opportunities)') + .command('archive [id]') + .description( + 'Archive a prospect, or the prospects for up to 1,000 records with --ids (ends their mail and keeps their lists)' + ) + .option('--ids ', 'Comma-separated record IDs to archive in bulk, e.g. prop_123,prop_456') + .option('--record-type ', 'With --ids: property (default), person, or company') .option('--no-cascade', 'Skip optional archive cleanup; mail always ends and deals stay open') .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm prospects archive prospect_8812 + dm prospects archive --ids prop_12345,prop_67890 + dm prospects archive --ids person_777,person_778 --record-type person` + ) .action(async (id, options) => { - await prospectsArchive(id, { json: options.json, noCascade: options.cascade === false }); + await prospectsArchive(id, { + json: options.json, + noCascade: options.cascade === false, + ids: options.ids, + recordType: options.recordType, + }); }); prospectsCmd @@ -1710,11 +1735,17 @@ prospectsCmd }); prospectsCmd - .command('reactivate ') - .description('Return an archived prospect to active') + .command('reactivate [id]') + .description('Return an archived prospect to active, or many by record ID with --ids') + .option('--ids ', 'Comma-separated record IDs to restore in bulk, e.g. prop_123,prop_456') + .option('--record-type ', 'With --ids: property (default), person, or company') .option('--json', 'Output as JSON') .action(async (id, options) => { - await prospectsReactivate(id, options); + await prospectsReactivate(id, { + json: options.json, + ids: options.ids, + recordType: options.recordType, + }); }); prospectsCmd diff --git a/src/version.ts b/src/version.ts index 1d20963..d4bb406 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const CLI_VERSION = '0.4.0-rc.1'; +export const CLI_VERSION = '0.4.1-rc.2'; export const CLI_USER_AGENT = `dm-cli/${CLI_VERSION}`; diff --git a/tests/commands/prospects.test.ts b/tests/commands/prospects.test.ts new file mode 100644 index 0000000..6e61b47 --- /dev/null +++ b/tests/commands/prospects.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { prospectsArchive, prospectsReactivate, prospectsGet } from '../../src/commands/prospects.js'; + +vi.mock('../../src/lib/config.js', () => ({ + readConfig: () => ({ apiKey: 'fixture-only', apiEnvironment: 'staging' }), +})); + +class ExitSignal extends Error { + constructor(public code: number) { + super(`exit ${code}`); + } +} + +let fetchMock: ReturnType; +let log: ReturnType; +let error: ReturnType; + +beforeEach(() => { + // Config and transport are isolated from local credentials and live services. + vi.stubEnv('DM_API_URL', 'https://api.test/v1'); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + log = vi.spyOn(console, 'log').mockImplementation(() => {}); + error = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new ExitSignal(Number(code ?? 0)); + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function lastRequest() { + const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit]; + return { url, method: init.method, body: JSON.parse(String(init.body)) }; +} + +const bulkResult = { + data: { + lifecycle: 'archived', + changed: 2, + already_in_lifecycle: 1, + not_prospects: 1, + changed_record_ids: ['prop_1', 'prop_2'], + }, +}; + +describe('dm prospects archive', () => { + it('archives one prospect by id through the single update', async () => { + fetchMock.mockResolvedValue( + jsonResponse(200, { data: { id: 'prospect_8812', lifecycle: 'archived' } }) + ); + + await prospectsArchive('prospect_8812', { noCascade: true }); + + const request = lastRequest(); + expect(request.url).toMatch(/\/prospects\/prospect_8812$/); + expect(request.method).toBe('PATCH'); + expect(request.body).toEqual({ lifecycle: 'archived', cascade: false }); + }); + + it('archives many records with --ids through the bulk move and itemizes the result', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, bulkResult)); + + await prospectsArchive(undefined, { ids: 'prop_1, prop_2,prop_3,prop_4', recordType: 'property' }); + + const request = lastRequest(); + expect(request.url).toMatch(/\/prospects$/); + expect(request.method).toBe('PATCH'); + expect(request.body).toEqual({ + record_ids: ['prop_1', 'prop_2', 'prop_3', 'prop_4'], + lifecycle: 'archived', + record_type: 'property', + }); + const printed = log.mock.calls.flat().join('\n'); + expect(printed).toContain('Prospects Archived'); + expect(printed).toMatch(/Archived\s+2/); + expect(printed).toMatch(/Already archived\s+1/); + expect(printed).toMatch(/Not prospects\s+1/); + }); + + it('prints the raw response with --json and passes --no-cascade through', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, bulkResult)); + + await prospectsArchive(undefined, { ids: 'prop_1', json: true, noCascade: true }); + + expect(lastRequest().body).toEqual({ + record_ids: ['prop_1'], + lifecycle: 'archived', + cascade: false, + }); + expect(log.mock.calls.flat().join('\n')).toContain('"changed_record_ids"'); + }); + + it('needs an id or --ids', async () => { + await expect(prospectsArchive(undefined, {})).rejects.toThrow(ExitSignal); + expect(error).toHaveBeenCalledWith(expect.stringContaining('pass a prospect ID, or --ids')); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('dm prospects reactivate', () => { + it('restores many records with --ids', async () => { + fetchMock.mockResolvedValue( + jsonResponse(200, { + data: { + lifecycle: 'active', + changed: 1, + already_in_lifecycle: 0, + not_prospects: 0, + changed_record_ids: ['person_9'], + }, + }) + ); + + await prospectsReactivate(undefined, { ids: 'person_9', recordType: 'person' }); + + expect(lastRequest().body).toEqual({ + record_ids: ['person_9'], + lifecycle: 'active', + record_type: 'person', + }); + expect(log.mock.calls.flat().join('\n')).toContain('Prospects Restored'); + }); +}); + +// Public output remains usable with both the additive field and older API responses. +describe('dm prospects status display', () => { + it.each([ + ['do_not_contact', 'Do Not Contact'], + ['follow_up', 'Follow up'], + [null, 'None'], + [undefined, 'None'], + ])('shows status %s on the prospect detail', async (status, label) => { + fetchMock.mockResolvedValue(jsonResponse(200, { data: { + id: 'prospect_8812', record_type: 'property', record_id: 'prop_1', + lifecycle: 'active', favorite: false, status, source: 'api', + record: { address: '123 Main St' }, tags: [], created_at: '2026-10-13T00:00:00Z', + } })); + await prospectsGet('prospect_8812', {}); + const printed = log.mock.calls.flat().join('\n'); + expect(printed).toContain('Status'); + expect(printed).toContain(label); + }); +}); diff --git a/tests/commands/publicApiExtensions.test.ts b/tests/commands/publicApiExtensions.test.ts index 6fab6d2..9551903 100644 --- a/tests/commands/publicApiExtensions.test.ts +++ b/tests/commands/publicApiExtensions.test.ts @@ -31,8 +31,8 @@ vi.mock('../../src/lib/output.js', () => ({ })); import { printJson, printTable } from '../../src/lib/output.js'; -import { peopleGet, peopleIds, peopleSearch } from '../../src/commands/people.js'; -import { propertiesGet, propertiesIds, propertiesSearch } from '../../src/commands/properties.js'; +import { peopleGet, peopleIds, peopleSearch, peopleExport } from '../../src/commands/people.js'; +import { propertiesGet, propertiesIds, propertiesSearch, propertiesExport } from '../../src/commands/properties.js'; import { enrichAddress, enrichApn, @@ -571,3 +571,22 @@ describe('CLI phone carrier (DEA-2144)', () => { }); }); + + +describe.each([ + ['people', peopleExport], + ['properties', propertiesExport], +] as const)('%s active phone exports', (source, exportRecords) => { + it.each([true, undefined])('preserves active phone selection %s at the API boundary', async (activePhonesOnly) => { + mockParseRequestBody.mockResolvedValue({ fields: ['phone_1'] }); + await exportRecords({ activePhonesOnly, json: true }); + expect(mockApiRequest).toHaveBeenCalledWith(`/${source}/export`, { + method: 'POST', + body: { + fields: ['phone_1'], + ...(source === 'properties' ? { anchor: 'person', contact_audience: 'owners' } : {}), + ...(activePhonesOnly ? { active_phones_only: true } : {}), + }, + }); + }); +}); diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts index acfa9f2..6b03a73 100644 --- a/tests/lib/config.test.ts +++ b/tests/lib/config.test.ts @@ -16,7 +16,7 @@ vi.mock('node:child_process', () => ({ }), })); -import { readConfig, writeConfig, deleteConfig, configExists, getConfigValue, setConfigValue } from '../../src/lib/config'; +import { readConfig, writeConfig, deleteConfig, setConfigValue } from '../../src/lib/config'; const CONFIG_DIR = path.join('/tmp/test-home', '.dealmachine'); const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); @@ -85,20 +85,6 @@ describe('CLI config', () => { expect(writtenConfig.credentialStore).toBe('encrypted-file'); expect(writtenConfig.credentialFile).toBe(CREDENTIAL_FILE); }); - - it('skips mkdir when directory exists', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - writeConfig(sampleConfig); - expect(fs.mkdirSync).not.toHaveBeenCalled(); - }); - - it('falls back when the OS credential store is unavailable', () => { - vi.mocked(fs.existsSync).mockImplementation((file) => file === CONFIG_DIR); - - writeConfig(sampleConfig); - - expect(getWrittenConfig().credentialStore).toBe('encrypted-file'); - }); }); describe('deleteConfig', () => { @@ -114,32 +100,6 @@ describe('CLI config', () => { }); }); - describe('configExists', () => { - it('returns true when file exists', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - expect(configExists()).toBe(true); - }); - - it('returns false when file does not exist', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - expect(configExists()).toBe(false); - }); - }); - - describe('getConfigValue', () => { - it('returns specific config value', () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(sampleConfig)); - expect(getConfigValue('apiKey')).toBe('dm_sk_live_test123'); - expect(getConfigValue('organizationId')).toBe(7); - }); - - it('returns null when no config', () => { - vi.mocked(fs.existsSync).mockReturnValue(false); - expect(getConfigValue('apiKey')).toBeNull(); - }); - }); - describe('setConfigValue', () => { it('updates a single config value', () => { vi.mocked(fs.existsSync).mockReturnValue(true); diff --git a/tests/lib/output.test.ts b/tests/lib/output.test.ts index d0113f0..634d703 100644 --- a/tests/lib/output.test.ts +++ b/tests/lib/output.test.ts @@ -9,7 +9,7 @@ vi.mock('chalk', () => { return { default: new Proxy({}, handler) }; }); -import { truncate, printTable, printJson, printKeyValue } from '../../src/lib/output'; +import { truncate, printTable, printJson } from '../../src/lib/output'; describe('output helpers', () => { beforeEach(() => { @@ -45,12 +45,6 @@ describe('output helpers', () => { expect(console.log).toHaveBeenCalledWith(expect.stringContaining('no results')); }); - it('prints header and rows', () => { - printTable([{ name: 'Alice', age: 30 }], ['name', 'age']); - // At least header + separator + 1 row = 3 calls - expect(vi.mocked(console.log).mock.calls.length).toBeGreaterThanOrEqual(3); - }); - it('auto-detects columns from first row', () => { printTable([{ x: 1, y: 2 }]); const allOutput = vi.mocked(console.log).mock.calls.map(c => c[0]).join('\n'); @@ -58,11 +52,4 @@ describe('output helpers', () => { expect(allOutput).toContain('y'); }); }); - - describe('printKeyValue', () => { - it('prints key-value pairs', () => { - printKeyValue({ name: 'Test', count: 42 }); - expect(vi.mocked(console.log).mock.calls.length).toBe(2); - }); - }); }); diff --git a/tests/release.test.ts b/tests/release.test.ts new file mode 100644 index 0000000..6a3cf5a --- /dev/null +++ b/tests/release.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { planRelease, verifyPublishedPackage } from '../scripts/release-plan.mjs'; + +const sourceSha = 'a'.repeat(40); +const otherSha = 'b'.repeat(40); +const published = (sha = otherSha, integrity = 'sha512-reviewed') => ({ + dealmachineRelease: { sourceSha: sha }, + dist: { integrity }, + dependencies: { '@dealmachine/cli': '0.4.1' }, +}); +const registry = (versions = {}, latest = '0.3.0') => ({ versions, 'dist-tags': { latest } }); +const registries = (implementation = registry(), alias = registry()) => ({ '@dealmachine/cli': implementation, dealmachine: alias }); + +describe('automatic CLI release contract', () => { + it.each([ + ['0.4.0', ['0.3.0', '0.4.0-rc.4'], '0.4.0'], + ['0.4.0', ['0.4.1', '0.4.10', '0.4.9'], '0.4.11'], + ['1.0.0', ['0.4.12'], '1.0.0'], + ])('selects a stable version from baseline %s and existing %j', (base, existing, expected) => { + const snapshot = registry(Object.fromEntries(existing.map(version => [version, published()]))); + expect(planRelease(base, sourceSha, registries(snapshot)).version).toBe(expected); + }); + + it('resumes the implementation version when alias publication failed', () => { + const snapshot = registry({ '0.4.1': published(sourceSha) }, '0.4.1'); + const plan = planRelease('0.4.0', sourceSha, registries(snapshot)); + expect(plan).toEqual({ version: '0.4.1', sourceSha, tag: 'latest' }); + expect(() => verifyPublishedPackage({ name: '@dealmachine/cli', integrity: 'sha512-reviewed' }, snapshot, plan, { requireTag: true })).not.toThrow(); + expect(() => verifyPublishedPackage({ name: 'dealmachine', integrity: 'sha512-reviewed' }, registry(), plan, { requireTag: true })).toThrow(/missing from npm/); + }); + + it('rejects an alias for another source before completing a partial release', () => { + expect(() => planRelease('0.4.0', sourceSha, registries( + registry({ '0.4.1': published(sourceSha) }), + registry({ '0.4.1': published(otherSha) }), + ))).toThrow(/mixed release/); + }); + + it('refuses a retry that would roll back a newer published version', () => { + expect(() => planRelease('0.4.0', sourceSha, registries(registry({ + '0.4.1': published(sourceSha), '0.4.2': published(otherSha), + })))).toThrow(/move latest backward/); + }); + + it.each([ + ['changed bytes', { ...published(sourceSha), dist: { integrity: 'sha512-different' } }, '0.4.1', /published bytes differ/], + ['wrong alias dependency', { ...published(sourceSha), dependencies: { '@dealmachine/cli': '^0.4.1' } }, '0.4.1', /exact implementation/], + ['unmoved channel', published(sourceSha), '0.3.0', /latest is not this version/], + ['wrong source', published(otherSha), '0.4.1', /source does not match/], + ])('does not report a successful release with %s', (_, metadata, latest, error) => { + expect(() => verifyPublishedPackage( + { name: 'dealmachine', integrity: 'sha512-reviewed' }, + registry({ '0.4.1': metadata }, latest), + { version: '0.4.1', tag: 'latest', sourceSha }, + { requireTag: true }, + )).toThrow(error); + }); +});