@pugi/cli 0.1.0-beta.3 → 0.1.0-beta.31
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/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -40
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/artifact-chain/dispatcher.js +148 -0
- package/dist/core/artifact-chain/exporter.js +164 -0
- package/dist/core/artifact-chain/state.js +243 -0
- package/dist/core/artifact-chain/steps.js +169 -0
- package/dist/core/auth/env-provider.js +238 -0
- package/dist/core/auto-update/channels.js +122 -0
- package/dist/core/auto-update/checker.js +241 -0
- package/dist/core/auto-update/state.js +235 -0
- package/dist/core/bare-mode/index.js +107 -0
- package/dist/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +208 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/bare-mode.js +42 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/pugi-md.js +89 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +111 -18
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +89 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +852 -210
- package/dist/core/engine/prompts.js +89 -6
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +972 -33
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/file-cache.js +113 -1
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/client.js +174 -29
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/memory/dual-write.js +416 -0
- package/dist/core/memory/dual-write.spec.js +297 -0
- package/dist/core/memory/phase1-kinds.js +20 -0
- package/dist/core/memory-sync/queue.js +158 -0
- package/dist/core/memory-sync/queue.spec.js +105 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +215 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/session-review.js +557 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +1529 -30
- package/dist/core/repl/slash-commands.js +361 -13
- package/dist/core/repl/store/session-store.js +31 -2
- package/dist/core/repl/workspace-context.js +22 -0
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/session.js +44 -0
- package/dist/core/settings.js +80 -0
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +2603 -278
- package/dist/runtime/commands/chain.js +489 -0
- package/dist/runtime/commands/compact.js +297 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +312 -0
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/doctor.js +390 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +212 -28
- package/dist/runtime/commands/mcp.js +824 -0
- package/dist/runtime/commands/memory.js +508 -0
- package/dist/runtime/commands/memory.spec.js +174 -0
- package/dist/runtime/commands/model.js +237 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/patch.js +17 -0
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/prd-check.js +285 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/roster.js +117 -0
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/commands/worktree.js +50 -6
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +229 -0
- package/dist/tools/apply-patch.js +281 -39
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +30 -2
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +81 -0
- package/dist/tui/conversation-pane.js +82 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +46 -0
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/input-box.js +46 -2
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/repl-render.js +293 -35
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +45 -13
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +28 -0
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/tool-stream-pane.js +7 -0
- package/dist/tui/update-banner.js +20 -2
- package/dist/tui/vim-input.js +267 -0
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +9 -6
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact chain state machine — Pugi α7 Wave 6 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Owns the on-disk persisted state for a single chain
|
|
5
|
+
* (`.pugi/chains/<chain-id>/chain.json`). A chain is a deterministic
|
|
6
|
+
* 7-step pipeline: each step is `pending` → `dispatched` → `complete`
|
|
7
|
+
* (terminal). The machine never auto-advances; operator review between
|
|
8
|
+
* steps is the gate. This file is pure with respect to the network and
|
|
9
|
+
* stays small enough to import from the REPL hot path without dragging
|
|
10
|
+
* the dispatcher graph along.
|
|
11
|
+
*
|
|
12
|
+
* Concurrency: the CLI is single-process per invocation; the on-disk
|
|
13
|
+
* format tolerates two simultaneous readers (snapshot is atomic via
|
|
14
|
+
* write-then-rename) but writers MUST take the per-chain directory as
|
|
15
|
+
* an implicit lock. Multi-writer races are out-of-scope — the operator
|
|
16
|
+
* runs one `pugi chain next` at a time.
|
|
17
|
+
*
|
|
18
|
+
* Backwards-compat: `schemaVersion` is pinned. Older chains with no
|
|
19
|
+
* version field default to `1`. Bump + add migration logic when the
|
|
20
|
+
* persisted shape changes.
|
|
21
|
+
*/
|
|
22
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync, readdirSync } from 'node:fs';
|
|
23
|
+
import { join, resolve } from 'node:path';
|
|
24
|
+
import { randomBytes } from 'node:crypto';
|
|
25
|
+
import { CHAIN_STEPS, CHAIN_STEP_IDS, CHAIN_STEP_COUNT, findStep, } from './steps.js';
|
|
26
|
+
/**
|
|
27
|
+
* Generate a new chain id. Format: `chn_<8 lowercase hex>`. Short
|
|
28
|
+
* enough to fit on one terminal column; collision probability is fine
|
|
29
|
+
* for the operator-scale workload (chains per workspace per session).
|
|
30
|
+
*/
|
|
31
|
+
export function generateChainId(rng = () => randomBytes(4)) {
|
|
32
|
+
return `chn_${rng().toString('hex')}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the directory that holds a single chain. Encapsulates the
|
|
36
|
+
* `.pugi/chains/<id>/` convention so every caller agrees.
|
|
37
|
+
*/
|
|
38
|
+
export function chainDir(workspaceCwd, chainId) {
|
|
39
|
+
return resolve(workspaceCwd, '.pugi', 'chains', chainId);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Path to the chain's state JSON. Atomic writes go through a sibling
|
|
43
|
+
* `chain.json.tmp` + rename so a crashed CLI cannot leave a truncated
|
|
44
|
+
* state file behind.
|
|
45
|
+
*/
|
|
46
|
+
export function chainStatePath(workspaceCwd, chainId) {
|
|
47
|
+
return join(chainDir(workspaceCwd, chainId), 'chain.json');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Initialise on-disk state for a fresh chain. Returns the persisted
|
|
51
|
+
* snapshot. Throws when the chain id already exists on disk so the
|
|
52
|
+
* caller is forced to handle the collision explicitly.
|
|
53
|
+
*/
|
|
54
|
+
export function createChain(workspaceCwd, intent, options = {}) {
|
|
55
|
+
const now = options.now ?? (() => new Date());
|
|
56
|
+
const id = options.chainId ?? generateChainId();
|
|
57
|
+
const dir = chainDir(workspaceCwd, id);
|
|
58
|
+
if (existsSync(dir)) {
|
|
59
|
+
throw new Error(`chain ${id} already exists at ${dir}`);
|
|
60
|
+
}
|
|
61
|
+
mkdirSync(dir, { recursive: true });
|
|
62
|
+
const createdAt = now().toISOString();
|
|
63
|
+
const trimmedIntent = intent.trim();
|
|
64
|
+
if (trimmedIntent.length === 0) {
|
|
65
|
+
throw new Error('chain intent must be non-empty');
|
|
66
|
+
}
|
|
67
|
+
const steps = CHAIN_STEPS.map((descriptor) => ({
|
|
68
|
+
id: descriptor.id,
|
|
69
|
+
status: 'pending',
|
|
70
|
+
updatedAt: createdAt,
|
|
71
|
+
dispatchId: null,
|
|
72
|
+
errorMessage: null,
|
|
73
|
+
}));
|
|
74
|
+
const state = {
|
|
75
|
+
schemaVersion: 1,
|
|
76
|
+
id,
|
|
77
|
+
intent: trimmedIntent,
|
|
78
|
+
createdAt,
|
|
79
|
+
updatedAt: createdAt,
|
|
80
|
+
nextStep: CHAIN_STEP_IDS[0] ?? null,
|
|
81
|
+
steps,
|
|
82
|
+
};
|
|
83
|
+
writeStateAtomic(workspaceCwd, state);
|
|
84
|
+
return Object.freeze(state);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Read the persisted state for a chain. Returns `null` when the chain
|
|
88
|
+
* does not exist on disk — callers render a friendly "no such chain"
|
|
89
|
+
* message instead of throwing.
|
|
90
|
+
*/
|
|
91
|
+
export function readChain(workspaceCwd, chainId) {
|
|
92
|
+
const path = chainStatePath(workspaceCwd, chainId);
|
|
93
|
+
if (!existsSync(path))
|
|
94
|
+
return null;
|
|
95
|
+
const raw = readFileSync(path, 'utf8');
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
if (parsed.schemaVersion !== 1 || typeof parsed.id !== 'string') {
|
|
98
|
+
throw new Error(`chain ${chainId} state is malformed (schemaVersion mismatch)`);
|
|
99
|
+
}
|
|
100
|
+
// Migrate any missing optional fields to defaults so a partially-
|
|
101
|
+
// hand-edited state file still loads.
|
|
102
|
+
const steps = Array.isArray(parsed.steps) ? parsed.steps : [];
|
|
103
|
+
if (steps.length !== CHAIN_STEP_COUNT) {
|
|
104
|
+
throw new Error(`chain ${chainId} state has ${steps.length} steps; expected ${CHAIN_STEP_COUNT}`);
|
|
105
|
+
}
|
|
106
|
+
return Object.freeze({
|
|
107
|
+
schemaVersion: 1,
|
|
108
|
+
id: parsed.id,
|
|
109
|
+
intent: parsed.intent ?? '',
|
|
110
|
+
createdAt: parsed.createdAt ?? new Date(0).toISOString(),
|
|
111
|
+
updatedAt: parsed.updatedAt ?? new Date(0).toISOString(),
|
|
112
|
+
nextStep: parsed.nextStep ?? null,
|
|
113
|
+
steps: steps.map((s) => ({
|
|
114
|
+
id: s.id,
|
|
115
|
+
status: (s.status ?? 'pending'),
|
|
116
|
+
updatedAt: s.updatedAt ?? parsed.updatedAt ?? new Date(0).toISOString(),
|
|
117
|
+
dispatchId: s.dispatchId ?? null,
|
|
118
|
+
errorMessage: s.errorMessage ?? null,
|
|
119
|
+
})),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* List every chain id present under `.pugi/chains/`. Returns an empty
|
|
124
|
+
* array when the directory does not exist — operators rarely call this
|
|
125
|
+
* before `pugi chain new`.
|
|
126
|
+
*/
|
|
127
|
+
export function listChainIds(workspaceCwd) {
|
|
128
|
+
const root = join(workspaceCwd, '.pugi', 'chains');
|
|
129
|
+
if (!existsSync(root))
|
|
130
|
+
return [];
|
|
131
|
+
return readdirSync(root, { withFileTypes: true })
|
|
132
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('chn_'))
|
|
133
|
+
.map((entry) => entry.name)
|
|
134
|
+
.sort();
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Mark a step as dispatched. Idempotent re-dispatch is allowed (the
|
|
138
|
+
* operator may re-run a step after a transient failure); the previous
|
|
139
|
+
* dispatchId is overwritten.
|
|
140
|
+
*/
|
|
141
|
+
export function markDispatched(workspaceCwd, chainId, stepId, dispatchId, options = {}) {
|
|
142
|
+
return transition(workspaceCwd, chainId, options.now ?? (() => new Date()), (state) => {
|
|
143
|
+
const step = state.steps.find((s) => s.id === stepId);
|
|
144
|
+
if (!step) {
|
|
145
|
+
throw new Error(`chain ${chainId}: unknown step ${stepId}`);
|
|
146
|
+
}
|
|
147
|
+
if (step.status === 'complete') {
|
|
148
|
+
throw new Error(`chain ${chainId}: step ${stepId} already complete`);
|
|
149
|
+
}
|
|
150
|
+
step.status = 'dispatched';
|
|
151
|
+
step.dispatchId = dispatchId;
|
|
152
|
+
step.errorMessage = null;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Mark a step as complete. Operator approves between steps so this is
|
|
157
|
+
* the final gate — when the operator runs `pugi chain next` AFTER the
|
|
158
|
+
* artifact lands, the dispatcher first flips the current step to
|
|
159
|
+
* complete then advances the cursor.
|
|
160
|
+
*/
|
|
161
|
+
export function markComplete(workspaceCwd, chainId, stepId, options = {}) {
|
|
162
|
+
return transition(workspaceCwd, chainId, options.now ?? (() => new Date()), (state) => {
|
|
163
|
+
const step = state.steps.find((s) => s.id === stepId);
|
|
164
|
+
if (!step) {
|
|
165
|
+
throw new Error(`chain ${chainId}: unknown step ${stepId}`);
|
|
166
|
+
}
|
|
167
|
+
step.status = 'complete';
|
|
168
|
+
step.errorMessage = null;
|
|
169
|
+
state.nextStep = firstPendingStep(state.steps);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Capture an error against the current step without flipping it to
|
|
174
|
+
* complete. The step stays in `dispatched` so the operator can re-run.
|
|
175
|
+
*/
|
|
176
|
+
export function markError(workspaceCwd, chainId, stepId, errorMessage, options = {}) {
|
|
177
|
+
return transition(workspaceCwd, chainId, options.now ?? (() => new Date()), (state) => {
|
|
178
|
+
const step = state.steps.find((s) => s.id === stepId);
|
|
179
|
+
if (!step) {
|
|
180
|
+
throw new Error(`chain ${chainId}: unknown step ${stepId}`);
|
|
181
|
+
}
|
|
182
|
+
step.errorMessage = errorMessage;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Compute the next pending step. Returns `null` when every step is
|
|
187
|
+
* complete (chain finalised).
|
|
188
|
+
*/
|
|
189
|
+
export function firstPendingStep(steps) {
|
|
190
|
+
for (const id of CHAIN_STEP_IDS) {
|
|
191
|
+
const record = steps.find((s) => s.id === id);
|
|
192
|
+
if (!record || record.status !== 'complete')
|
|
193
|
+
return id;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Resolve the descriptor for the current cursor. Returns `null` when
|
|
199
|
+
* the chain is finalised. Convenience wrapper so renderers do not need
|
|
200
|
+
* to handle the lookup themselves.
|
|
201
|
+
*/
|
|
202
|
+
export function currentStepDescriptor(state) {
|
|
203
|
+
if (!state.nextStep)
|
|
204
|
+
return null;
|
|
205
|
+
return findStep(state.nextStep) ?? null;
|
|
206
|
+
}
|
|
207
|
+
/* ------------------------------------------------------------------ */
|
|
208
|
+
/* Internal: transactional mutate helper */
|
|
209
|
+
/* ------------------------------------------------------------------ */
|
|
210
|
+
function transition(workspaceCwd, chainId, now, mutate) {
|
|
211
|
+
const current = readChain(workspaceCwd, chainId);
|
|
212
|
+
if (!current) {
|
|
213
|
+
throw new Error(`chain ${chainId} not found at ${chainStatePath(workspaceCwd, chainId)}`);
|
|
214
|
+
}
|
|
215
|
+
// Deep clone so the frozen snapshot is never mutated.
|
|
216
|
+
const draft = JSON.parse(JSON.stringify(current));
|
|
217
|
+
mutate(draft);
|
|
218
|
+
draft.updatedAt = now().toISOString();
|
|
219
|
+
// Update step-level timestamp for whichever step the mutation
|
|
220
|
+
// touched. We diff status / dispatchId / errorMessage against the
|
|
221
|
+
// previous snapshot — any change bumps the step's updatedAt.
|
|
222
|
+
for (const draftStep of draft.steps) {
|
|
223
|
+
const previousStep = current.steps.find((s) => s.id === draftStep.id);
|
|
224
|
+
if (!previousStep)
|
|
225
|
+
continue;
|
|
226
|
+
if (draftStep.status !== previousStep.status ||
|
|
227
|
+
draftStep.dispatchId !== previousStep.dispatchId ||
|
|
228
|
+
draftStep.errorMessage !== previousStep.errorMessage) {
|
|
229
|
+
draftStep.updatedAt = draft.updatedAt;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
writeStateAtomic(workspaceCwd, draft);
|
|
233
|
+
return Object.freeze(draft);
|
|
234
|
+
}
|
|
235
|
+
function writeStateAtomic(workspaceCwd, state) {
|
|
236
|
+
const dir = chainDir(workspaceCwd, state.id);
|
|
237
|
+
mkdirSync(dir, { recursive: true });
|
|
238
|
+
const finalPath = chainStatePath(workspaceCwd, state.id);
|
|
239
|
+
const tmpPath = `${finalPath}.tmp`;
|
|
240
|
+
writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
241
|
+
renameSync(tmpPath, finalPath);
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact chain step definitions — Pugi α7 Wave 6 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* The chain encodes Pugi's moat: an operator drops one high-level intent
|
|
5
|
+
* (`pugi chain new "<intent>"`) and the CLI walks a deterministic 7-step
|
|
6
|
+
* pipeline that produces verifiable artifacts at each stage. Every step
|
|
7
|
+
* dispatches to a specialist persona via the existing `pugi delegate`
|
|
8
|
+
* surface so the same Tier 1 roster powers both ad-hoc dispatch and the
|
|
9
|
+
* artifact chain.
|
|
10
|
+
*
|
|
11
|
+
* The pipeline:
|
|
12
|
+
*
|
|
13
|
+
* 1. prd — Olivia (PM): structured PRD (goals / users /
|
|
14
|
+
* acceptance criteria / scope)
|
|
15
|
+
* 2. adr — Marcus (CTO): architectural decision record
|
|
16
|
+
* against the team ADR template
|
|
17
|
+
* 3. mindmap — Marcus (CTO): mermaid mindmap of the solution
|
|
18
|
+
* surface (modules + boundaries)
|
|
19
|
+
* 4. er — Hiroshi (Lead Dev):entity-relationship mermaid for
|
|
20
|
+
* the data model the design implies
|
|
21
|
+
* 5. sequence — Hiroshi (Lead Dev):mermaid sequence diagram covering
|
|
22
|
+
* the critical happy + sad paths
|
|
23
|
+
* 6. tests — Vera (QA): spec scaffolding mapped from the
|
|
24
|
+
* PRD acceptance criteria
|
|
25
|
+
* 7. code — Hiroshi (Lead Dev):implementation diff against the
|
|
26
|
+
* scaffolded specs
|
|
27
|
+
*
|
|
28
|
+
* Persona slugs are lowercase ASCII to satisfy the server-side delegate
|
|
29
|
+
* grammar (`^[a-z]+$`, mirrors `PUGI_DELEGATE_REGEX` in
|
|
30
|
+
* `apps/admin-api/src/pugi/sessions.controller.ts`). The chain never
|
|
31
|
+
* invents new personas — it only orchestrates the existing roster.
|
|
32
|
+
*
|
|
33
|
+
* Module contract:
|
|
34
|
+
*
|
|
35
|
+
* - This file is PURE data. No fs, no network, no side effects.
|
|
36
|
+
* Importing it from a hot path (REPL keystroke handler) is safe.
|
|
37
|
+
* - The step ORDER is authoritative; downstream consumers MUST iterate
|
|
38
|
+
* `CHAIN_STEPS` instead of hand-rolling their own arrays so future
|
|
39
|
+
* re-ordering lands in exactly one place.
|
|
40
|
+
* - Personas are looked up by slug at dispatch time so a roster change
|
|
41
|
+
* does not silently break the chain — the dispatcher surfaces an
|
|
42
|
+
* `unknown_persona` outcome the operator can see.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Ordered, frozen table of the seven steps. The chain state machine
|
|
46
|
+
* advances through this array exactly once; re-ordering OR insertion
|
|
47
|
+
* is a breaking change for any chain currently on disk.
|
|
48
|
+
*/
|
|
49
|
+
export const CHAIN_STEPS = Object.freeze([
|
|
50
|
+
{
|
|
51
|
+
id: 'prd',
|
|
52
|
+
ordinal: 1,
|
|
53
|
+
persona: 'olivia',
|
|
54
|
+
personaLabel: 'Olivia (PM)',
|
|
55
|
+
artifactFilename: 'PRD.md',
|
|
56
|
+
briefTemplate: 'Produce a structured PRD for chain {{chainId}}. ' +
|
|
57
|
+
'Operator intent: "{{intent}}". ' +
|
|
58
|
+
'Cover: goals, target users, success metrics, ' +
|
|
59
|
+
'numbered acceptance criteria (## Acceptance Criteria), scope + non-scope, ' +
|
|
60
|
+
'risks. Output markdown ready for prd-check ingestion.',
|
|
61
|
+
gloss: 'Structured PRD — goals, users, acceptance criteria, scope',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'adr',
|
|
65
|
+
ordinal: 2,
|
|
66
|
+
persona: 'marcus',
|
|
67
|
+
personaLabel: 'Marcus (CTO)',
|
|
68
|
+
artifactFilename: 'ADR.md',
|
|
69
|
+
briefTemplate: 'Produce an architectural decision record for chain {{chainId}}. ' +
|
|
70
|
+
'Read the PRD at .pugi/chains/{{chainId}}/PRD.md verbatim. ' +
|
|
71
|
+
'Follow the team ADR template: Status, Context, Decision, ' +
|
|
72
|
+
'Consequences, Alternatives Considered. ' +
|
|
73
|
+
'Be explicit about boundary changes the decision implies.',
|
|
74
|
+
gloss: 'Architectural decision record per team ADR template',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'mindmap',
|
|
78
|
+
ordinal: 3,
|
|
79
|
+
persona: 'marcus',
|
|
80
|
+
personaLabel: 'Marcus (CTO)',
|
|
81
|
+
artifactFilename: 'MINDMAP.mmd',
|
|
82
|
+
briefTemplate: 'Produce a mermaid mindmap of the solution surface for chain {{chainId}}. ' +
|
|
83
|
+
'Read PRD + ADR at .pugi/chains/{{chainId}}/PRD.md and ADR.md. ' +
|
|
84
|
+
'Root node = the feature name; first-level branches = subsystems; ' +
|
|
85
|
+
'leaves = concrete modules / endpoints / tables. ' +
|
|
86
|
+
'Output ONE mermaid `mindmap` fenced block — no prose.',
|
|
87
|
+
gloss: 'Mermaid mindmap — solution surface (subsystems + modules)',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'er',
|
|
91
|
+
ordinal: 4,
|
|
92
|
+
persona: 'hiroshi',
|
|
93
|
+
personaLabel: 'Hiroshi (Lead Dev)',
|
|
94
|
+
artifactFilename: 'ER.mmd',
|
|
95
|
+
briefTemplate: 'Produce a mermaid entity-relationship diagram for chain {{chainId}}. ' +
|
|
96
|
+
'Source: PRD + ADR + MINDMAP under .pugi/chains/{{chainId}}/. ' +
|
|
97
|
+
'Use `erDiagram` syntax. Cover every persisted entity the ' +
|
|
98
|
+
'design implies; mark PK / FK relationships explicitly.',
|
|
99
|
+
gloss: 'Mermaid ER diagram — persisted entities + relationships',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'sequence',
|
|
103
|
+
ordinal: 5,
|
|
104
|
+
persona: 'hiroshi',
|
|
105
|
+
personaLabel: 'Hiroshi (Lead Dev)',
|
|
106
|
+
artifactFilename: 'SEQUENCE.mmd',
|
|
107
|
+
briefTemplate: 'Produce mermaid sequence diagrams for chain {{chainId}}. ' +
|
|
108
|
+
'Cover the critical happy path + at least one sad path. ' +
|
|
109
|
+
'Source: PRD acceptance criteria + ER diagram. ' +
|
|
110
|
+
'Use `sequenceDiagram` syntax; one diagram per fenced block.',
|
|
111
|
+
gloss: 'Mermaid sequence diagrams — happy + sad path flows',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'tests',
|
|
115
|
+
ordinal: 6,
|
|
116
|
+
persona: 'vera',
|
|
117
|
+
personaLabel: 'Vera (QA)',
|
|
118
|
+
artifactFilename: 'TESTS.md',
|
|
119
|
+
briefTemplate: 'Produce spec scaffolding for chain {{chainId}}. ' +
|
|
120
|
+
'Map every numbered PRD acceptance criterion (under ## Acceptance Criteria) ' +
|
|
121
|
+
'to one or more concrete test descriptions using node:test + node:assert. ' +
|
|
122
|
+
'Format: criterion-id → test file path → `it(...)` blocks. ' +
|
|
123
|
+
'Output markdown only — no executable code.',
|
|
124
|
+
gloss: 'Spec scaffolding — PRD criteria mapped to test descriptions',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'code',
|
|
128
|
+
ordinal: 7,
|
|
129
|
+
persona: 'hiroshi',
|
|
130
|
+
personaLabel: 'Hiroshi (Lead Dev)',
|
|
131
|
+
artifactFilename: 'CODE.md',
|
|
132
|
+
briefTemplate: 'Produce an implementation plan + diff references for chain {{chainId}}. ' +
|
|
133
|
+
'Read PRD, ADR, MINDMAP, ER, SEQUENCE, TESTS under .pugi/chains/{{chainId}}/. ' +
|
|
134
|
+
'Output: file-by-file changes (path, change kind, summary), ' +
|
|
135
|
+
'with each change tied back to a PRD criterion id. ' +
|
|
136
|
+
'NO inline patches — patches land via `pugi patch apply` after operator review.',
|
|
137
|
+
gloss: 'Implementation plan — file changes mapped to PRD criteria',
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
/**
|
|
141
|
+
* Resolve a step descriptor by id. Returns `undefined` for unknown ids
|
|
142
|
+
* so callers can surface a structured error instead of panicking.
|
|
143
|
+
*/
|
|
144
|
+
export function findStep(id) {
|
|
145
|
+
return CHAIN_STEPS.find((step) => step.id === id);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Index of step ids in chain order. Exported so the state machine can
|
|
149
|
+
* answer "what is the next step after X?" without re-scanning the
|
|
150
|
+
* table on every transition.
|
|
151
|
+
*/
|
|
152
|
+
export const CHAIN_STEP_IDS = Object.freeze(CHAIN_STEPS.map((s) => s.id));
|
|
153
|
+
/**
|
|
154
|
+
* Total number of steps in the chain. Hard-coded constant exported so
|
|
155
|
+
* downstream renderers can size their progress bars without iterating.
|
|
156
|
+
*/
|
|
157
|
+
export const CHAIN_STEP_COUNT = CHAIN_STEPS.length;
|
|
158
|
+
/**
|
|
159
|
+
* Interpolate the brief template with chain context. The template
|
|
160
|
+
* grammar is intentionally minimal — only `{{chainId}}` and `{{intent}}`
|
|
161
|
+
* are honoured. Unknown placeholders are left verbatim so a template
|
|
162
|
+
* typo surfaces in the dispatched brief instead of silently dropping.
|
|
163
|
+
*/
|
|
164
|
+
export function renderBrief(template, ctx) {
|
|
165
|
+
return template
|
|
166
|
+
.replace(/\{\{chainId\}\}/g, ctx.chainId)
|
|
167
|
+
.replace(/\{\{intent\}\}/g, ctx.intent);
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=steps.js.map
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi login --provider env` — env-var auth path (Leak L35).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code, Codex CLI, and gh CLI all ship a way to authenticate via
|
|
5
|
+
* an environment variable so CI / container / scripted contexts can
|
|
6
|
+
* skip the device flow entirely. This module backs that path:
|
|
7
|
+
*
|
|
8
|
+
* 1. Resolve the candidate token (explicit `--key` flag beats
|
|
9
|
+
* `PUGI_API_KEY` env — same precedence as `gh auth login --token`).
|
|
10
|
+
* 2. Run a cheap local format check so an obviously malformed key
|
|
11
|
+
* (empty, whitespace, suspiciously short) fails fast WITHOUT
|
|
12
|
+
* shipping it to the server (no observability leak into the
|
|
13
|
+
* Anvil access log).
|
|
14
|
+
* 3. Call `GET /api/pugi/health` with `Authorization: Bearer <key>`
|
|
15
|
+
* so an expired / revoked / typo'd token surfaces immediately
|
|
16
|
+
* and the credential file never lands on disk for a dead key.
|
|
17
|
+
* 4. Map response to typed outcome the CLI dispatcher can render.
|
|
18
|
+
*
|
|
19
|
+
* The module is intentionally pure — fetch + reading env are injected,
|
|
20
|
+
* the writer is a separate concern. The CLI dispatcher composes
|
|
21
|
+
* `resolveEnvCandidateToken` + `assertTokenFormat` + `validateTokenAgainstHealth`
|
|
22
|
+
* and then writes the credential via `storeApiKey` on success.
|
|
23
|
+
*
|
|
24
|
+
* Failure modes are explicit so the dispatcher can pick the user-facing
|
|
25
|
+
* remediation string without re-parsing strings:
|
|
26
|
+
*
|
|
27
|
+
* - `missing` → no token in env or --key, halt with hint
|
|
28
|
+
* - `invalid-format` → token failed local format check, halt
|
|
29
|
+
* - `unauthorized` → server rejected the token (401 / 403)
|
|
30
|
+
* - `network-error` → fetch threw (DNS, refused, TLS)
|
|
31
|
+
* - `server-error` → server returned 5xx (transient — operator
|
|
32
|
+
* may want to retry once)
|
|
33
|
+
* - `unexpected-status`→ anything else non-2xx (treat as failure)
|
|
34
|
+
*
|
|
35
|
+
* NEVER log the raw token. Memory hits
|
|
36
|
+
* `feedback_no_claude_attribution_anywhere_hard_rule` plus the
|
|
37
|
+
* CSO bearer-leak sweep apply here. Use `maskApiKey` from
|
|
38
|
+
* `core/credentials.ts` when the dispatcher needs to surface the key
|
|
39
|
+
* to the operator.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* The minimum length below which we refuse to even ship the token to
|
|
43
|
+
* the server. Pugi-issued PATs are 48+ chars (`pugi_<32 base32>`), JWTs
|
|
44
|
+
* issued by the device flow are ~250 chars, legacy `sk-*` PATs we
|
|
45
|
+
* accept for compatibility are 32+. 16 is well below all three real
|
|
46
|
+
* shapes so it only catches obvious paste mistakes.
|
|
47
|
+
*/
|
|
48
|
+
export const MIN_TOKEN_LENGTH = 16;
|
|
49
|
+
/**
|
|
50
|
+
* The set of prefixes we recognise as plausibly-real Pugi-shaped
|
|
51
|
+
* tokens. Loose by design — the real validator is the server-side
|
|
52
|
+
* health probe. We just want to catch an operator who pasted the
|
|
53
|
+
* wrong string entirely (a username, a URL, a placeholder like
|
|
54
|
+
* "<your-key>") before it reaches the network.
|
|
55
|
+
*
|
|
56
|
+
* Three-segment JWTs are also accepted via the `looksLikeJwt`
|
|
57
|
+
* predicate so device-flow tokens copied out of `~/.pugi/credentials.json`
|
|
58
|
+
* on a different machine work.
|
|
59
|
+
*/
|
|
60
|
+
export const RECOGNISED_TOKEN_PREFIXES = ['pugi_', 'sk_', 'sk-', 'pat_'];
|
|
61
|
+
/**
|
|
62
|
+
* Returns the trimmed candidate token, or `null` when neither path
|
|
63
|
+
* produced one. Precedence: explicit flag arg beats env var (matches
|
|
64
|
+
* `gh auth login --with-token`, `aws configure set`, and `pugi config`
|
|
65
|
+
* which all prefer the most-specific operator intent over the ambient
|
|
66
|
+
* env).
|
|
67
|
+
*/
|
|
68
|
+
export function resolveEnvCandidateToken(input) {
|
|
69
|
+
const explicit = input.explicitKey?.trim();
|
|
70
|
+
if (explicit)
|
|
71
|
+
return explicit;
|
|
72
|
+
const env = input.env ?? process.env;
|
|
73
|
+
const fromEnv = env.PUGI_API_KEY?.trim();
|
|
74
|
+
if (fromEnv)
|
|
75
|
+
return fromEnv;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Local-only format check. Returns `null` on accept, a human-readable
|
|
80
|
+
* error string on reject. Deliberately lenient — the server-side
|
|
81
|
+
* health probe is the source of truth. We only catch obvious paste
|
|
82
|
+
* mistakes (empty, whitespace-laden, too short, looks like a URL or
|
|
83
|
+
* a placeholder).
|
|
84
|
+
*/
|
|
85
|
+
export function assertTokenFormat(token) {
|
|
86
|
+
if (!token)
|
|
87
|
+
return 'Token is empty';
|
|
88
|
+
if (/\s/.test(token)) {
|
|
89
|
+
return 'Token contains whitespace — check for shell quoting issues or a stray newline';
|
|
90
|
+
}
|
|
91
|
+
if (token.length < MIN_TOKEN_LENGTH) {
|
|
92
|
+
return `Token too short (${token.length} chars; Pugi tokens are >= ${MIN_TOKEN_LENGTH})`;
|
|
93
|
+
}
|
|
94
|
+
if (token.startsWith('<') && token.endsWith('>')) {
|
|
95
|
+
return 'Token looks like a placeholder (`<your-key>`) — replace with the actual key';
|
|
96
|
+
}
|
|
97
|
+
if (/^https?:\/\//i.test(token)) {
|
|
98
|
+
return 'Token looks like a URL — did you mean --api-url?';
|
|
99
|
+
}
|
|
100
|
+
// Accept either a recognised prefix OR a JWT three-segment shape.
|
|
101
|
+
// Anything else still passes — the server probe will catch genuinely
|
|
102
|
+
// unknown keys. We just want to surface an obvious mistake.
|
|
103
|
+
const hasKnownPrefix = RECOGNISED_TOKEN_PREFIXES.some((p) => token.startsWith(p));
|
|
104
|
+
if (!hasKnownPrefix && !looksLikeJwt(token)) {
|
|
105
|
+
// Soft-fail: warn the operator but proceed. Returning null here
|
|
106
|
+
// would mask the case where the operator pasted something
|
|
107
|
+
// genuinely wrong but the server happens to accept it (impossible
|
|
108
|
+
// for real keys but defence-in-depth). Returning the warning
|
|
109
|
+
// string would block legacy keys. We choose to proceed — the
|
|
110
|
+
// server is the source of truth — and let the CLI dispatcher
|
|
111
|
+
// decide whether to surface a note. Tracked via a separate
|
|
112
|
+
// `warnUnknownPrefix` return on a future revision.
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* JWT three-segment check. Does NOT verify the signature — we just
|
|
119
|
+
* want to recognise the shape so device-flow tokens copied from one
|
|
120
|
+
* machine to another pass the format gate.
|
|
121
|
+
*/
|
|
122
|
+
export function looksLikeJwt(token) {
|
|
123
|
+
const parts = token.split('.');
|
|
124
|
+
if (parts.length !== 3)
|
|
125
|
+
return false;
|
|
126
|
+
return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 0);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Call `GET /api/pugi/health` with the candidate token. Returns a
|
|
130
|
+
* typed outcome that the CLI dispatcher can map directly to an exit
|
|
131
|
+
* code + remediation string.
|
|
132
|
+
*
|
|
133
|
+
* Health endpoint conventions (see apps/admin-api):
|
|
134
|
+
* - 200 → token is valid, account is active
|
|
135
|
+
* - 401 → token unknown / malformed at the server boundary
|
|
136
|
+
* - 403 → token recognised but the account is suspended / paused
|
|
137
|
+
* - 5xx → server-side issue, operator can retry
|
|
138
|
+
* - network throw → DNS, refused, TLS — operator's connectivity issue
|
|
139
|
+
*
|
|
140
|
+
* We do not parse the body — the health endpoint's contract is the
|
|
141
|
+
* status code. Any future field (latency, region, build sha) can be
|
|
142
|
+
* surfaced by a separate `pugi doctor` probe without touching the
|
|
143
|
+
* login path.
|
|
144
|
+
*/
|
|
145
|
+
export async function validateTokenAgainstHealth(input) {
|
|
146
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
147
|
+
const now = input.now ?? Date.now;
|
|
148
|
+
const url = `${stripTrailingSlash(input.apiUrl)}/api/pugi/health`;
|
|
149
|
+
const started = now();
|
|
150
|
+
let response;
|
|
151
|
+
try {
|
|
152
|
+
response = await fetchImpl(url, {
|
|
153
|
+
method: 'GET',
|
|
154
|
+
headers: {
|
|
155
|
+
Authorization: `Bearer ${input.apiKey}`,
|
|
156
|
+
Accept: 'application/json',
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
// DNS failure, ECONNREFUSED, TLS handshake — anything that makes
|
|
162
|
+
// fetch throw before a status code is observable. We deliberately
|
|
163
|
+
// do NOT echo the URL host in the message body if it could leak a
|
|
164
|
+
// self-hosted Anvil hostname into a public CI log; the dispatcher
|
|
165
|
+
// composes the user-facing remediation.
|
|
166
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
167
|
+
return {
|
|
168
|
+
kind: 'network-error',
|
|
169
|
+
message: `Cannot reach ${input.apiUrl}; check your connection`,
|
|
170
|
+
cause,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const latencyMs = now() - started;
|
|
174
|
+
const { status } = response;
|
|
175
|
+
if (status === 200) {
|
|
176
|
+
return { kind: 'ok', latencyMs };
|
|
177
|
+
}
|
|
178
|
+
if (status === 401 || status === 403) {
|
|
179
|
+
return {
|
|
180
|
+
kind: 'unauthorized',
|
|
181
|
+
status,
|
|
182
|
+
message: status === 401
|
|
183
|
+
? 'Token invalid or expired — run `pugi login --provider device` to get a fresh one'
|
|
184
|
+
: 'Token recognised but the account is suspended — check `pugi whoami` on a working machine or contact support',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (status >= 500) {
|
|
188
|
+
return {
|
|
189
|
+
kind: 'server-error',
|
|
190
|
+
status,
|
|
191
|
+
message: `${input.apiUrl} returned ${status}; retry in a moment`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
kind: 'unexpected-status',
|
|
196
|
+
status,
|
|
197
|
+
message: `Unexpected ${status} from /api/pugi/health; treat as login failure`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export async function resolveAndValidateEnvLogin(input) {
|
|
201
|
+
const token = resolveEnvCandidateToken({
|
|
202
|
+
explicitKey: input.explicitKey,
|
|
203
|
+
env: input.env,
|
|
204
|
+
});
|
|
205
|
+
if (!token) {
|
|
206
|
+
return {
|
|
207
|
+
kind: 'missing',
|
|
208
|
+
message: 'pugi login --provider env requires a token. Export PUGI_API_KEY in the current shell or pass --key <value>.',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const formatError = assertTokenFormat(token);
|
|
212
|
+
if (formatError) {
|
|
213
|
+
return {
|
|
214
|
+
kind: 'invalid-format',
|
|
215
|
+
message: `pugi login --provider env: ${formatError}`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (input.skipValidate) {
|
|
219
|
+
// Used by the existing `login-variants.spec.ts` regression suite
|
|
220
|
+
// so the test plane does not require a live network. Production
|
|
221
|
+
// path always validates.
|
|
222
|
+
return { kind: 'ok', token, latencyMs: 0 };
|
|
223
|
+
}
|
|
224
|
+
const probe = await validateTokenAgainstHealth({
|
|
225
|
+
apiUrl: input.apiUrl,
|
|
226
|
+
apiKey: token,
|
|
227
|
+
fetchImpl: input.fetchImpl,
|
|
228
|
+
now: input.now,
|
|
229
|
+
});
|
|
230
|
+
if (probe.kind === 'ok') {
|
|
231
|
+
return { kind: 'ok', token, latencyMs: probe.latencyMs };
|
|
232
|
+
}
|
|
233
|
+
return probe;
|
|
234
|
+
}
|
|
235
|
+
function stripTrailingSlash(url) {
|
|
236
|
+
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=env-provider.js.map
|