@nextcommerce/campaigns-os 1.37.3 → 1.41.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +114 -10
- package/CHANGELOG.md +530 -0
- package/README.md +38 -27
- package/contracts/agent-relevant-change-policy.v1.json +11 -1
- package/contracts/effects.v1.json +4794 -0
- package/contracts/release-ledger.json +789 -0
- package/contracts/supported-surface.json +25 -5
- package/docs/build-packet.md +27 -16
- package/docs/demo-preview.md +1 -1
- package/docs/diagnostics.md +7 -4
- package/docs/effects.md +281 -0
- package/docs/gateway-login.md +113 -0
- package/docs/orientation-contract-reference.md +4 -1
- package/docs/progress-snapshots.md +3 -3
- package/docs/qa-and-test-orders.md +3 -3
- package/docs/readback.md +523 -0
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +364 -0
- package/docs/supported-surface.md +11 -3
- package/docs/versioning.md +8 -4
- package/package.json +8 -3
- package/schemas/campaign-runtime-build-packet.v0.schema.json +5 -0
- package/schemas/campaigns-os-effects.v1.schema.json +211 -0
- package/schemas/campaigns-os-readback.v2.schema.json +267 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +174 -0
- package/skills/campaign-readback-classification/SKILL.md +230 -0
- package/skills/campaign-run-evidence/SKILL.md +140 -0
- package/skills/contribution-intake/SKILL.md +85 -0
- package/skills/next-campaigns-build/SKILL.md +33 -12
- package/skills/next-campaigns-os/SKILL.md +45 -21
- package/skills/next-campaigns-os-setup/SKILL.md +35 -14
- package/skills/next-campaigns-polish/SKILL.md +43 -17
- package/skills/next-campaigns-qa/SKILL.md +48 -24
- package/skills.json +39 -6
- package/src/admin-transport.mjs +123 -0
- package/src/cli.mjs +991 -200
- package/src/credential-store.mjs +183 -0
- package/src/deviation.mjs +3 -2
- package/src/diagnostic.mjs +4 -1
- package/src/gate-actions.mjs +2 -2
- package/src/install-mode.mjs +17 -9
- package/src/lifecycle.mjs +95 -0
- package/src/login.mjs +152 -0
- package/src/package-install-fixture.mjs +3 -2
- package/src/qa-node.mjs +56 -19
- package/src/qa-publish.mjs +108 -2
- package/src/readback.mjs +1936 -0
- package/src/remit.mjs +17 -3
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const fail = () => new Error('Credential storage is unavailable or unsafe. Check your user credential directory/keychain; no credential was printed. For a broken keychain login, run campaigns-os logout --store <store> to clear its local selection.');
|
|
8
|
+
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
9
|
+
const SERVICE = 'com.nextcommerce.campaigns-os';
|
|
10
|
+
const MAX_BYTES = 65536;
|
|
11
|
+
|
|
12
|
+
// No secret is placed in argv or the environment. security's interactive mode
|
|
13
|
+
// accepts its command on a private pipe; hex encoding avoids its command parser.
|
|
14
|
+
export function macKeychain({ run = spawnSync, platform = process.platform } = {}) {
|
|
15
|
+
const invoke = (args, input) => {
|
|
16
|
+
let result;
|
|
17
|
+
try { result = run('/usr/bin/security', args, { input, encoding: 'utf8', timeout: 5000, maxBuffer: MAX_BYTES * 3, stdio: ['pipe', 'pipe', 'pipe'] }); }
|
|
18
|
+
catch { throw fail(); }
|
|
19
|
+
if (result.error || result.signal) throw fail();
|
|
20
|
+
return result;
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
available: platform === 'darwin' && fs.existsSync('/usr/bin/security'),
|
|
24
|
+
read(account) {
|
|
25
|
+
const result = invoke(['find-generic-password', '-s', SERVICE, '-a', account, '-w']);
|
|
26
|
+
if (result.status === 44) return null;
|
|
27
|
+
if (result.status !== 0) throw fail();
|
|
28
|
+
// Native security prints non-ASCII password bytes as lowercase hex.
|
|
29
|
+
// This provider stores JSON objects only: never guess how to decode an
|
|
30
|
+
// arbitrary password, and reject malformed UTF-8 rather than replacing it.
|
|
31
|
+
if (typeof result.stdout !== 'string' || Buffer.byteLength(result.stdout) > MAX_BYTES * 2 + 2) throw fail();
|
|
32
|
+
let value = result.stdout.trimEnd();
|
|
33
|
+
if (!value.startsWith('{')) {
|
|
34
|
+
if (!/^(?:[a-f0-9]{2})+$/.test(value) || !value.startsWith('7b') || !value.endsWith('7d')) throw fail();
|
|
35
|
+
try { value = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(value, 'hex')); }
|
|
36
|
+
catch { throw fail(); }
|
|
37
|
+
}
|
|
38
|
+
if (Buffer.byteLength(value) > MAX_BYTES) throw fail();
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(value);
|
|
41
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw fail();
|
|
42
|
+
} catch { throw fail(); }
|
|
43
|
+
return value;
|
|
44
|
+
},
|
|
45
|
+
write(account, value) {
|
|
46
|
+
// account is an internally generated SHA256, never user input.
|
|
47
|
+
if (!/^[a-f0-9]{64}$/.test(account)) throw fail();
|
|
48
|
+
const result = invoke(['-i'], `add-generic-password -U -s ${SERVICE} -a ${account} -X ${Buffer.from(value).toString('hex')}\n`);
|
|
49
|
+
if (result.status !== 0 || this.read(account) !== value) throw fail();
|
|
50
|
+
},
|
|
51
|
+
clear(account) {
|
|
52
|
+
const result = invoke(['delete-generic-password', '-s', SERVICE, '-a', account]);
|
|
53
|
+
if (result.status !== 0 && result.status !== 44) throw fail();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createCredentialStore({ home = os.homedir(), keychain = macKeychain(), uid = process.getuid?.(), lockTimeoutMs = 3000 } = {}) {
|
|
59
|
+
const root = path.resolve(home, '.campaigns-os');
|
|
60
|
+
const directory = path.join(root, 'credentials');
|
|
61
|
+
const inspect = (name, directoryExpected, create = false, privateLeaf = true) => {
|
|
62
|
+
if (create) { try { fs.mkdirSync(name, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw fail(); } }
|
|
63
|
+
let stat;
|
|
64
|
+
try { stat = fs.lstatSync(name); } catch (error) { if (!create && error.code === 'ENOENT') return null; throw fail(); }
|
|
65
|
+
if (stat.isSymbolicLink() || (directoryExpected ? !stat.isDirectory() : !stat.isFile()) || (uid !== undefined && stat.uid !== uid) || (privateLeaf ? (stat.mode & 0o777) !== (directoryExpected ? 0o700 : 0o600) : (stat.mode & 0o022)) || (!directoryExpected && (stat.nlink !== 1 || stat.size > MAX_BYTES))) throw fail();
|
|
66
|
+
return stat;
|
|
67
|
+
};
|
|
68
|
+
const prepare = () => {
|
|
69
|
+
// This fixed user-state path does not depend on cwd or project markers.
|
|
70
|
+
// A dotfiles repository at home must not turn it into campaign storage.
|
|
71
|
+
const h = fs.lstatSync(home);
|
|
72
|
+
if (!h.isDirectory() || h.isSymbolicLink() || (uid !== undefined && h.uid !== uid)) throw fail();
|
|
73
|
+
inspect(root, true, true, false); inspect(directory, true, true);
|
|
74
|
+
};
|
|
75
|
+
const identifier = binding => createHash('sha256').update(JSON.stringify([binding.gateway, binding.client_id, binding.store])).digest('hex');
|
|
76
|
+
return {
|
|
77
|
+
async transaction(binding, operation) {
|
|
78
|
+
try { prepare(); } catch { throw fail(); }
|
|
79
|
+
const id = identifier(binding), file = path.join(directory, id + '.json'), lock = path.join(directory, id + '.lock');
|
|
80
|
+
const deadline = Date.now() + lockTimeoutMs;
|
|
81
|
+
for (;;) {
|
|
82
|
+
try { fs.mkdirSync(lock, { mode: 0o700 }); break; }
|
|
83
|
+
catch (error) { if (error.code !== 'EEXIST') throw fail(); inspect(lock, true); if (Date.now() >= deadline) throw new Error('Credential storage is busy. Retry after the other login or refresh finishes. If a process was interrupted, confirm no campaigns-os process is running before removing its stale .lock directory in your user credential store.'); await wait(50); }
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const readFile = () => {
|
|
87
|
+
if (!inspect(file, false)) return null;
|
|
88
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
89
|
+
try {
|
|
90
|
+
const stat = fs.fstatSync(fd);
|
|
91
|
+
if (!stat.isFile() || (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1 || stat.size > MAX_BYTES) throw fail();
|
|
92
|
+
return fs.readFileSync(fd, 'utf8');
|
|
93
|
+
} finally { fs.closeSync(fd); }
|
|
94
|
+
};
|
|
95
|
+
const parse = raw => {
|
|
96
|
+
if (typeof raw !== 'string' || Buffer.byteLength(raw) > MAX_BYTES) throw fail();
|
|
97
|
+
try { return JSON.parse(raw); } catch { throw fail(); }
|
|
98
|
+
};
|
|
99
|
+
let stored = readFile();
|
|
100
|
+
let pointer = stored === null ? null : parse(stored);
|
|
101
|
+
let keychainPointer = pointer?.backend === 'keychain';
|
|
102
|
+
if (keychainPointer && (!/^[a-f0-9]{64}$/.test(pointer.account) || !keychain.available)) throw fail();
|
|
103
|
+
// A private pointer commits a staged keychain item atomically. A failed
|
|
104
|
+
// keychain write never replaces the previously valid login. Existing
|
|
105
|
+
// file-backed records stay file-backed instead of silently migrating.
|
|
106
|
+
const useKeychain = keychainPointer || (pointer === null && keychain.available);
|
|
107
|
+
const read = () => {
|
|
108
|
+
const raw = keychainPointer ? keychain.read(pointer.account) : stored;
|
|
109
|
+
if (raw === null) { if (keychainPointer) throw fail(); return null; }
|
|
110
|
+
const record = parse(raw);
|
|
111
|
+
if (record.gateway !== binding.gateway || record.client_id !== binding.client_id || record.store !== binding.store) throw fail();
|
|
112
|
+
return record;
|
|
113
|
+
};
|
|
114
|
+
const atomicFile = value => {
|
|
115
|
+
inspect(file, false);
|
|
116
|
+
const temporary = path.join(directory, id + '.' + randomUUID() + '.tmp');
|
|
117
|
+
let fd;
|
|
118
|
+
try {
|
|
119
|
+
fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
120
|
+
fs.writeFileSync(fd, value); fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
|
|
121
|
+
fs.renameSync(temporary, file);
|
|
122
|
+
} finally { if (fd !== undefined) fs.closeSync(fd); if (fs.existsSync(temporary)) fs.unlinkSync(temporary); }
|
|
123
|
+
};
|
|
124
|
+
const write = record => {
|
|
125
|
+
if (record.gateway !== binding.gateway || record.client_id !== binding.client_id || record.store !== binding.store) throw fail();
|
|
126
|
+
const value = JSON.stringify(record);
|
|
127
|
+
if (Buffer.byteLength(value) > MAX_BYTES) throw fail();
|
|
128
|
+
if (!useKeychain) { atomicFile(value); stored = value; pointer = record; keychainPointer = false; return; }
|
|
129
|
+
const account = createHash('sha256').update(id + randomUUID()).digest('hex');
|
|
130
|
+
const oldAccount = keychainPointer ? pointer.account : null;
|
|
131
|
+
const nextPointer = { backend: 'keychain', account, ...binding };
|
|
132
|
+
try {
|
|
133
|
+
keychain.write(account, value);
|
|
134
|
+
atomicFile(JSON.stringify(nextPointer));
|
|
135
|
+
pointer = nextPointer; stored = JSON.stringify(nextPointer); keychainPointer = true;
|
|
136
|
+
} catch (error) { try { keychain.clear(account); } catch { /* staged item is never selected */ } throw error; }
|
|
137
|
+
if (oldAccount) { try { keychain.clear(oldAccount); } catch { /* old item is no longer selected */ } }
|
|
138
|
+
};
|
|
139
|
+
const clear = () => {
|
|
140
|
+
let keychainRemoved = true;
|
|
141
|
+
try { if (keychainPointer) keychain.clear(pointer.account); }
|
|
142
|
+
catch { keychainRemoved = false; }
|
|
143
|
+
if (inspect(file, false)) fs.unlinkSync(file);
|
|
144
|
+
stored = null; pointer = null; keychainPointer = false;
|
|
145
|
+
return { local_cleared: true, keychain_item_removed: keychainRemoved };
|
|
146
|
+
};
|
|
147
|
+
return await operation({ read, write, clear });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// Do not attach subprocess output or filesystem content to exceptions.
|
|
150
|
+
if (error instanceof Error && error.message.startsWith('Credential storage')) throw error;
|
|
151
|
+
throw fail();
|
|
152
|
+
} finally { try { fs.rmdirSync(lock); } catch { throw fail(); } }
|
|
153
|
+
},
|
|
154
|
+
async listBindings() {
|
|
155
|
+
try {
|
|
156
|
+
if (!inspect(root, true, false, false) || !inspect(directory, true)) return [];
|
|
157
|
+
const names = fs.readdirSync(directory).filter(name => /^[a-f0-9]{64}\.json$/.test(name));
|
|
158
|
+
if (names.length > 1024) throw fail();
|
|
159
|
+
const bindings = [];
|
|
160
|
+
for (const name of names) {
|
|
161
|
+
const file = path.join(directory, name); inspect(file, false);
|
|
162
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
163
|
+
let value;
|
|
164
|
+
try {
|
|
165
|
+
const stat = fs.fstatSync(fd);
|
|
166
|
+
if (!stat.isFile() || stat.size > MAX_BYTES || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || (uid !== undefined && stat.uid !== uid)) throw fail();
|
|
167
|
+
value = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
168
|
+
} finally { fs.closeSync(fd); }
|
|
169
|
+
if (value?.backend === 'keychain' && !value.store) {
|
|
170
|
+
if (!keychain.available || !/^[a-f0-9]{64}$/.test(value.account)) throw fail();
|
|
171
|
+
value = JSON.parse(keychain.read(value.account));
|
|
172
|
+
}
|
|
173
|
+
if (!value || typeof value.gateway !== 'string' || typeof value.client_id !== 'string' || typeof value.store !== 'string') throw fail();
|
|
174
|
+
const binding = { gateway: value.gateway, client_id: value.client_id, store: value.store };
|
|
175
|
+
if (identifier(binding) + '.json' !== name) throw fail();
|
|
176
|
+
bindings.push(binding);
|
|
177
|
+
}
|
|
178
|
+
return bindings;
|
|
179
|
+
} catch { throw fail(); }
|
|
180
|
+
},
|
|
181
|
+
read(binding) { return this.transaction(binding, storage => storage.read()); },
|
|
182
|
+
};
|
|
183
|
+
}
|
package/src/deviation.mjs
CHANGED
|
@@ -30,13 +30,14 @@ const EXPECTED_COMMANDS_BY_STAGE = Object.freeze({
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
// The command word of a produced command line, whichever install prefix it
|
|
33
|
-
// was spelled with (bare `campaigns-os`, `npx campaigns-os`,
|
|
33
|
+
// was spelled with (bare `campaigns-os`, `npx --no-install campaigns-os`, the
|
|
34
|
+
// `npx campaigns-os` that versions before 1.41.2 printed, `npm run
|
|
34
35
|
// campaigns-os --`, or `npx --yes <git-spec>` from an npx cache).
|
|
35
36
|
export function commandWord(command) {
|
|
36
37
|
if (typeof command !== "string") return null;
|
|
37
38
|
const stripped = command
|
|
38
39
|
.replace(/^npx\s+--yes\s+\S+\s+/, "campaigns-os ")
|
|
39
|
-
.replace(/^npx\s+campaigns-os\s+/, "campaigns-os ")
|
|
40
|
+
.replace(/^npx\s+(?:--no-install\s+)?campaigns-os\s+/, "campaigns-os ")
|
|
40
41
|
.replace(/^npm\s+run\s+campaigns-os\s+--\s+/, "campaigns-os ");
|
|
41
42
|
return stripped.match(/^campaigns-os\s+([a-z-]+)/)?.[1] || null;
|
|
42
43
|
}
|
package/src/diagnostic.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// A projection, never a scrubber: source objects and free text never enter
|
|
2
2
|
// the export. Unknown producer values have fixed, non-actionable markers.
|
|
3
3
|
const MODES = new Set(["checkout", "node_modules", "global", "npx_cache", "package_directory"]);
|
|
4
|
-
|
|
4
|
+
// The export's platform allowlist. `installed` is a scope label, not a
|
|
5
|
+
// platform: no --platform was given and status checked only the platforms
|
|
6
|
+
// with Campaigns OS skills installed. It is never a --platform value.
|
|
7
|
+
const PLATFORMS = new Set(["claude", "codex", "agents", "all", "installed"]);
|
|
5
8
|
const STAGES = new Set(["prepare-build", "doctor-blocked", "setup", "build", "polish", "deploy", "qa", "done"]);
|
|
6
9
|
const STATUSES = new Set(["ready", "attention_required", "ready_with_warnings", "ready_with_waivers", "blocked"]);
|
|
7
10
|
const REASONS = new Set([
|
package/src/gate-actions.mjs
CHANGED
|
@@ -95,8 +95,8 @@ export function requiredActionText(action, { packetPath = null, reportPath = nul
|
|
|
95
95
|
command = `${command} --report ${shellToken(reportPath)}`;
|
|
96
96
|
}
|
|
97
97
|
// Registry commands are stored bare; the printed text is spelled for the
|
|
98
|
-
// install this package runs from (bare from a checkout,
|
|
99
|
-
// from a campaign folder), once, here.
|
|
98
|
+
// install this package runs from (bare from a checkout,
|
|
99
|
+
// `npx --no-install campaigns-os` from a campaign folder), once, here.
|
|
100
100
|
if (command && command.startsWith("campaigns-os ")) {
|
|
101
101
|
return `${invocationPrefixFor(PACKAGE_ROOT)} ${command.slice("campaigns-os ".length)}`;
|
|
102
102
|
}
|
package/src/install-mode.mjs
CHANGED
|
@@ -17,6 +17,13 @@ const PACKAGE_INSTALL_MODE_LABELS = Object.freeze({
|
|
|
17
17
|
|
|
18
18
|
const PUBLIC_GIT_SOURCE = "github:NextCommerceCo/campaigns-os";
|
|
19
19
|
|
|
20
|
+
// How a consumer install is spelled. `campaigns-os` is only the bin name of
|
|
21
|
+
// @nextcommerce/campaigns-os: where no installed copy is found, a plain
|
|
22
|
+
// `npx campaigns-os` looks the bin name up as a registry package and, with no
|
|
23
|
+
// terminal to ask, installs whatever answers. `--no-install` makes npx run the
|
|
24
|
+
// project's copy or fail.
|
|
25
|
+
export const LOCAL_INVOCATION_PREFIX = "npx --no-install campaigns-os";
|
|
26
|
+
|
|
20
27
|
function realpathOrSelf(path) {
|
|
21
28
|
try {
|
|
22
29
|
return realpathSync(path);
|
|
@@ -216,13 +223,13 @@ function runCommand(command, args) {
|
|
|
216
223
|
|
|
217
224
|
// How a command should be spelled so it runs THIS install: the checkout
|
|
218
225
|
// script from a checkout; `npx --yes <spec>` from an npx cache (nothing is on
|
|
219
|
-
// PATH); `npx campaigns-os` from a consumer install (the toolkit
|
|
220
|
-
// devDependency of the campaign folder) — always, because `npx`
|
|
221
|
-
// node_modules/.bin on PATH for the duration of the command, so a
|
|
222
|
-
// seen here says nothing about the operator's shell, and a bare
|
|
223
|
-
// paste there would not resolve; the bare binary from a plain
|
|
224
|
-
// directory. The PATH comparison is still reported so a shadowing
|
|
225
|
-
// visible.
|
|
226
|
+
// PATH); `npx --no-install campaigns-os` from a consumer install (the toolkit
|
|
227
|
+
// pinned as a devDependency of the campaign folder) — always, because `npx`
|
|
228
|
+
// itself puts node_modules/.bin on PATH for the duration of the command, so a
|
|
229
|
+
// PATH match seen here says nothing about the operator's shell, and a bare
|
|
230
|
+
// command they paste there would not resolve; the bare binary from a plain
|
|
231
|
+
// package directory. The PATH comparison is still reported so a shadowing
|
|
232
|
+
// install is visible.
|
|
226
233
|
export function resolveInvocation(root, pkg = {}, install = localInstallStatus(root, pkg)) {
|
|
227
234
|
const binRel = pkg.bin && typeof pkg.bin === "object"
|
|
228
235
|
? pkg.bin["campaigns-os"]
|
|
@@ -237,7 +244,7 @@ export function resolveInvocation(root, pkg = {}, install = localInstallStatus(r
|
|
|
237
244
|
let prefix;
|
|
238
245
|
if (install.mode === "checkout") prefix = "npm run campaigns-os --";
|
|
239
246
|
else if (install.mode === "npx_cache") prefix = install.pinned?.spec ? `npx --yes ${install.pinned.spec}` : "campaigns-os";
|
|
240
|
-
else if (install.mode === "node_modules") prefix =
|
|
247
|
+
else if (install.mode === "node_modules") prefix = LOCAL_INVOCATION_PREFIX;
|
|
241
248
|
else if (install.mode === "global") prefix = matches ? "campaigns-os" : `node ${shellToken(localBin)}`;
|
|
242
249
|
else prefix = "campaigns-os";
|
|
243
250
|
return {
|
|
@@ -281,4 +288,5 @@ export function invocationPrefixFor(root, pkg = null) {
|
|
|
281
288
|
// registries, tests) keeps the canonical spelling; only what is printed or
|
|
282
289
|
// emitted for an operator or agent to copy is rewritten. Skill names such as
|
|
283
290
|
// next-campaigns-os-setup, file names (campaigns-os.mjs), and already-prefixed
|
|
284
|
-
// forms (`npx campaigns-os`, `npm run campaigns-os --`) are left
|
|
291
|
+
// forms (`npx --no-install campaigns-os`, `npm run campaigns-os --`) are left
|
|
292
|
+
// alone.
|
package/src/lifecycle.mjs
CHANGED
|
@@ -13,12 +13,107 @@
|
|
|
13
13
|
// so the CLI exit code is unchanged, and lifecycle persistence is opt-in and
|
|
14
14
|
// non-fatal. No network, no credentials.
|
|
15
15
|
|
|
16
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
16
17
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
17
18
|
import { dirname, resolve } from "node:path";
|
|
18
19
|
|
|
19
20
|
export const LIFECYCLE_SCHEMA = "campaigns-os-command-lifecycle/v0";
|
|
20
21
|
export const LIFECYCLE_JOURNAL_REL_PATH = ".campaign-runtime/command-lifecycle.jsonl";
|
|
21
22
|
|
|
23
|
+
// A refusal raised BEFORE the command's handler ran: an unknown top-level
|
|
24
|
+
// command, an unknown subcommand, or a flag the command refuses up front.
|
|
25
|
+
// Every such throw site builds its error here so the lifecycle journal can
|
|
26
|
+
// recognize a refusal from ONE place (the CLI's persist step) instead of
|
|
27
|
+
// matching messages — a typo must not materialize a file under the target.
|
|
28
|
+
// Only the tag is added: the message is passed through and the exit code is
|
|
29
|
+
// untouched, because bin/campaigns-os.mjs special-cases filesystem errno codes
|
|
30
|
+
// only and falls through to the same `campaigns-os: <message>` / exit 1 path.
|
|
31
|
+
// One mechanism, read two ways. The tag travels on the thrown error for the
|
|
32
|
+
// usual path (onFinish is handed the error), and `refusalSeen()` records the
|
|
33
|
+
// same verdict for the paths that CATCH a refusal to render it — the CLI's
|
|
34
|
+
// `waiveOrRefuse` prints the `{ ok: false, error }` body under --json and
|
|
35
|
+
// returns, so onFinish gets no `thrown` at all.
|
|
36
|
+
//
|
|
37
|
+
// That caught-refusal verdict is per INVOCATION, not per module. main() runs
|
|
38
|
+
// its body inside `runWithRefusalScope`, which puts a fresh `{ seen: false }`
|
|
39
|
+
// in an AsyncLocalStorage store; `refused()` marks the store that is active
|
|
40
|
+
// where the refusal is raised and `refusalSeen()` reads the store active where
|
|
41
|
+
// persistence runs. A module-global flag was wrong: two in-process main() calls
|
|
42
|
+
// interleave (the second one's reset cleared the first one's verdict before its
|
|
43
|
+
// onFinish ran, and the refused invocation journaled an entry). AsyncLocalStorage
|
|
44
|
+
// follows the await chain, so each invocation sees only its own verdict without
|
|
45
|
+
// threading a holder through every throw site. Outside any scope — a command
|
|
46
|
+
// module calling `refused()` directly in a unit test — there is no store and
|
|
47
|
+
// `refusalSeen()` is false; the error tag is added either way.
|
|
48
|
+
//
|
|
49
|
+
// LIMITATION, stated so it is not mistaken for a bug: AsyncLocalStorage
|
|
50
|
+
// propagates the store into callbacks scheduled inside the scope (a
|
|
51
|
+
// setImmediate/setTimeout/unawaited callback still sees it), so a deferred
|
|
52
|
+
// `refused()` does mark the store — but it may do so AFTER the invocation's
|
|
53
|
+
// persistence step has already run and read `refusalSeen()` as false, so the
|
|
54
|
+
// journal entry would already be written while the error carries the tag.
|
|
55
|
+
// This is not defended against, because refusals are synchronous BY CONTRACT:
|
|
56
|
+
// they are raised up front, before the handler runs, on the same tick as the
|
|
57
|
+
// argv check that rejects the invocation. A refusal that needs to be deferred
|
|
58
|
+
// is not an up-front refusal and should be a handler failure instead — which is
|
|
59
|
+
// journaled, as it should be.
|
|
60
|
+
//
|
|
61
|
+
// This lives here, not in the CLI, because refusals are raised in command
|
|
62
|
+
// modules too (`qa`'s unknown subcommand) and those modules are imported BY
|
|
63
|
+
// cli.mjs: importing the factory back out of cli.mjs would be circular, and
|
|
64
|
+
// re-listing the subcommands anywhere else would be a second command list.
|
|
65
|
+
// lifecycle.mjs imports nothing from this repository, so it is safe to import
|
|
66
|
+
// from anywhere.
|
|
67
|
+
export const REFUSED_INVOCATION = "refused_invocation";
|
|
68
|
+
const refusalScope = new AsyncLocalStorage();
|
|
69
|
+
|
|
70
|
+
/** Run `fn` with its own refusal verdict. Returns whatever `fn` returns. */
|
|
71
|
+
export function runWithRefusalScope(fn) {
|
|
72
|
+
return refusalScope.run({ seen: false }, fn);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a tagged refusal. INVARIANT: a refusal must be thrown or rendered —
|
|
77
|
+
* never built and swallowed. The scope is marked HERE, at construction, not at
|
|
78
|
+
* the throw, because the paths that catch a refusal to render it (waiveOrRefuse)
|
|
79
|
+
* hand onFinish no error to inspect. The cost of marking early is that a
|
|
80
|
+
* `refused()` built inside a `try` that discards it would suppress the journal
|
|
81
|
+
* entry for an invocation whose handler did run. No call site does that today
|
|
82
|
+
* (no `requireArg` sits inside a `try`), and none may: if you need to probe
|
|
83
|
+
* whether an argument is present, test for it — do not construct a refusal
|
|
84
|
+
* speculatively.
|
|
85
|
+
*/
|
|
86
|
+
export function refused(message) {
|
|
87
|
+
const store = refusalScope.getStore();
|
|
88
|
+
if (store) store.seen = true;
|
|
89
|
+
const error = new Error(message);
|
|
90
|
+
error.code = REFUSED_INVOCATION;
|
|
91
|
+
return error;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function refusalSeen() {
|
|
95
|
+
return refusalScope.getStore()?.seen === true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run `fn()` and re-throw anything it throws as a tagged refusal, message
|
|
100
|
+
* byte-identical. The contract it encodes: THE TAG IS APPLIED AT THE UP-FRONT
|
|
101
|
+
* CALL SITE, NOT INSIDE THE VALIDATOR. The validators this wraps are shared —
|
|
102
|
+
* `assertSecureProxyBase` also runs mid-handler on the remit rail, and the
|
|
103
|
+
* order-creation limit is re-checked after a browser has launched — and a throw
|
|
104
|
+
* from those positions is a handler failure, the most valuable lifecycle entry
|
|
105
|
+
* there is. Only the caller knows it is checking argv before anything has been
|
|
106
|
+
* resolved, read, written, or launched, so only the caller may say "refusal".
|
|
107
|
+
* Wrap the up-front call; leave the shared validator untagged.
|
|
108
|
+
*/
|
|
109
|
+
export function refusing(fn) {
|
|
110
|
+
try {
|
|
111
|
+
return fn();
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw refused(error.message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
22
117
|
function isNonEmptyString(value) {
|
|
23
118
|
return typeof value === "string" && value.trim().length > 0;
|
|
24
119
|
}
|
package/src/login.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createCredentialStore } from './credential-store.mjs';
|
|
2
|
+
|
|
3
|
+
export const GATEWAY = 'https://mcp.nextcommerce.com';
|
|
4
|
+
export const CLIENT_ID = 'campaigns-os-owned-store-pilot';
|
|
5
|
+
export const RESOURCE = GATEWAY + '/campaigns';
|
|
6
|
+
export const SCOPE = 'campaigns:read';
|
|
7
|
+
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
8
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
9
|
+
const safeString = (value, max = 8192) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/u.test(value);
|
|
10
|
+
class GatewayError extends Error {
|
|
11
|
+
constructor(code, status = 0) { super('Gateway request failed.'); this.code = code; this.status = status; }
|
|
12
|
+
}
|
|
13
|
+
export function canonicalLoginStore(value) {
|
|
14
|
+
if (typeof value !== 'string') throw new Error('Provide --store <subdomain> or --store <subdomain.29next.store>.');
|
|
15
|
+
const host = value.trim().toLowerCase();
|
|
16
|
+
const label = host.endsWith('.29next.store') ? host.slice(0, -13) : host;
|
|
17
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) throw new Error('Provide --store <subdomain> or --store <subdomain.29next.store>.');
|
|
18
|
+
return label + '.29next.store';
|
|
19
|
+
}
|
|
20
|
+
export function authenticationArguments(argv) {
|
|
21
|
+
const command = argv[0];
|
|
22
|
+
if (!['login', 'logout'].includes(command) || !(argv.length === 1 || (argv.length === 3 && argv[1] === '--store'))) {
|
|
23
|
+
throw new Error('Use: campaigns-os login [--store <subdomain>], or campaigns-os logout [--store <subdomain>]. No token arguments are accepted.');
|
|
24
|
+
}
|
|
25
|
+
return { command, store: argv.length === 3 ? canonicalLoginStore(argv[2]) : null };
|
|
26
|
+
}
|
|
27
|
+
async function askStore() {
|
|
28
|
+
const { createInterface } = await import('node:readline/promises');
|
|
29
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
+
try { return await prompt.question('Store subdomain (or network domain): '); }
|
|
31
|
+
finally { prompt.close(); }
|
|
32
|
+
}
|
|
33
|
+
const bindingFor = store => ({ gateway: GATEWAY, client_id: CLIENT_ID, store });
|
|
34
|
+
|
|
35
|
+
// Deliberately no configurable origin/discovery and no response-body errors.
|
|
36
|
+
export async function gatewayRequest(route, { form, accessToken, fetchImpl = globalThis.fetch, timeoutMs = 15000 } = {}) {
|
|
37
|
+
if (!['/device/authorize', '/token', '/revoke'].includes(route)) throw new GatewayError('invalid_request');
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
let reader, timer;
|
|
40
|
+
const work = async () => {
|
|
41
|
+
const headers = { Accept: 'application/json' };
|
|
42
|
+
if (form) headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
43
|
+
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
|
44
|
+
const response = await fetchImpl(GATEWAY + route, { method: 'POST', headers, body: form ? new URLSearchParams(form).toString() : undefined, redirect: 'error', signal: controller.signal });
|
|
45
|
+
if (response.redirected || (response.url && response.url !== GATEWAY + route)) throw new GatewayError('invalid_response');
|
|
46
|
+
if (response.headers.get('content-type')?.split(';')[0] !== 'application/json') throw new GatewayError('invalid_response', response.status);
|
|
47
|
+
reader = response.body?.getReader();
|
|
48
|
+
if (!reader) throw new GatewayError('invalid_response', response.status);
|
|
49
|
+
const chunks = []; let bytes = 0;
|
|
50
|
+
for (;;) {
|
|
51
|
+
const { value, done } = await reader.read(); if (done) break;
|
|
52
|
+
bytes += value.byteLength; if (bytes > 32768) throw new GatewayError('invalid_response'); chunks.push(value);
|
|
53
|
+
}
|
|
54
|
+
let data;
|
|
55
|
+
try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new GatewayError('invalid_response', response.status); }
|
|
56
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new GatewayError('invalid_response', response.status);
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const code = ['authorization_pending', 'slow_down', 'access_denied', 'expired_token', 'invalid_grant', 'invalid_token', 'invalid_request', 'temporarily_unavailable'].includes(data.error) ? data.error : 'request_refused';
|
|
59
|
+
throw new GatewayError(code, response.status);
|
|
60
|
+
}
|
|
61
|
+
return data;
|
|
62
|
+
};
|
|
63
|
+
try {
|
|
64
|
+
return await Promise.race([work(), new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new GatewayError('unavailable')); }, Math.max(1, Math.min(timeoutMs, 15000))); })]);
|
|
65
|
+
} catch (error) { throw error instanceof GatewayError ? error : new GatewayError('unavailable'); }
|
|
66
|
+
finally { clearTimeout(timer); controller.abort(); void reader?.cancel().catch(() => {}); }
|
|
67
|
+
}
|
|
68
|
+
export function credentialFromResponse(data, store, receivedAt) {
|
|
69
|
+
if (!safeString(data.access_token) || !safeString(data.refresh_token) || data.access_token === data.refresh_token || data.token_type !== 'Bearer' || data.resource !== RESOURCE || data.scope !== SCOPE || !Number.isInteger(data.expires_in) || data.expires_in <= 0 || data.expires_in > 3600 || !safeString(data.grant_id, 256) || !safeString(data.gateway_version, 128)) throw new GatewayError('invalid_response');
|
|
70
|
+
return { schema_version: 1, ...bindingFor(store), resource: RESOURCE, scope: SCOPE, token_type: 'Bearer', access_token: data.access_token, refresh_token: data.refresh_token, access_expires_at: receivedAt + data.expires_in * 1000, received_at: receivedAt, grant_id: data.grant_id, gateway_version: data.gateway_version };
|
|
71
|
+
}
|
|
72
|
+
function validateStored(record) {
|
|
73
|
+
if (record.schema_version !== 1 || record.resource !== RESOURCE || record.scope !== SCOPE || record.token_type !== 'Bearer' || !safeString(record.access_token) || !safeString(record.refresh_token) || !Number.isFinite(record.access_expires_at)) throw new GatewayError('invalid_response');
|
|
74
|
+
}
|
|
75
|
+
function loginFailure(error) {
|
|
76
|
+
if (error?.status === 401) return new Error('Gateway refused authorization. No new credentials were saved. Run campaigns-os login again.');
|
|
77
|
+
if (error?.code === 'access_denied') return new Error('Connection declined. No new credentials were saved.');
|
|
78
|
+
if (error?.code === 'expired_token') return new Error('Connection code expired. Run campaigns-os login again.');
|
|
79
|
+
if (error?.code === 'invalid_request') return new Error('Gateway refused this store or client. Check the network-domain store and gateway availability.');
|
|
80
|
+
return new Error('Could not complete gateway login. Check gateway availability and retry; no new credentials were saved.');
|
|
81
|
+
}
|
|
82
|
+
export async function runAuthentication(argv, { fetchImpl = globalThis.fetch, credentials = createCredentialStore(), now = Date.now, pause = sleep, output = console.log, interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY), promptStore = askStore } = {}) {
|
|
83
|
+
const args = authenticationArguments(argv);
|
|
84
|
+
if (!args.store && !interactive) throw new Error("Use --store <subdomain> for noninteractive login or logout. No network request was made.");
|
|
85
|
+
const command = args.command;
|
|
86
|
+
const store = args.store ?? canonicalLoginStore(await promptStore());
|
|
87
|
+
const binding = bindingFor(store);
|
|
88
|
+
if (command === 'logout') {
|
|
89
|
+
let remote = 'no_local_grant', localIssue = false;
|
|
90
|
+
let cleanup = { local_cleared: false, keychain_item_removed: true };
|
|
91
|
+
await credentials.transaction(binding, async storage => {
|
|
92
|
+
try {
|
|
93
|
+
let record;
|
|
94
|
+
try { record = storage.read(); if (record) validateStored(record); }
|
|
95
|
+
catch { localIssue = true; remote = 'not_attempted'; return; }
|
|
96
|
+
if (!record) return;
|
|
97
|
+
try {
|
|
98
|
+
// Include network/clock skew in the decision; never retry a consumed
|
|
99
|
+
// refresh blindly after an uncertain response.
|
|
100
|
+
if (!record.refresh_pending && record.access_expires_at <= now() + 60000) {
|
|
101
|
+
const { rotateLockedCredential } = await import('./admin-transport.mjs');
|
|
102
|
+
record = await rotateLockedCredential(storage, record, { fetchImpl, now });
|
|
103
|
+
}
|
|
104
|
+
// Pending refresh means its outcome is unknown. Try access revocation
|
|
105
|
+
// once, never replay that refresh, then clear the local selection.
|
|
106
|
+
const result = await gatewayRequest('/revoke', { fetchImpl, accessToken: record.access_token });
|
|
107
|
+
if (result.revoked !== true) throw new GatewayError('invalid_response');
|
|
108
|
+
remote = 'revoked';
|
|
109
|
+
} catch (error) { remote = error?.status === 401 || error?.httpStatus === 401 ? 'unrecognized' : 'unconfirmed'; }
|
|
110
|
+
} finally { cleanup = storage.clear(); }
|
|
111
|
+
});
|
|
112
|
+
output(localIssue ? 'Local login selection cleared. Local credentials could not be read; remote revocation was not attempted.' : remote === 'revoked' ? 'Logged out. Gateway grant revoked and local login selection cleared.' : remote === 'unrecognized' ? 'Local login selection cleared. Gateway no longer recognizes this grant; remote revocation was not confirmed.' : remote === 'unconfirmed' ? 'Local login selection cleared. Remote revocation was not confirmed because the gateway request failed.' : 'No local login for this store.');
|
|
113
|
+
if (!cleanup.keychain_item_removed) output('An unselected keychain item could not be deleted. Check the com.nextcommerce.campaigns-os items in Keychain Access.');
|
|
114
|
+
return { ok: true, store, remote_revocation: remote, ...cleanup };
|
|
115
|
+
}
|
|
116
|
+
// Capture existing pair before network activity. Recheck under lock on save
|
|
117
|
+
// so another process's successful login/refresh/logout cannot be overwritten.
|
|
118
|
+
const previous = await credentials.read(binding);
|
|
119
|
+
let record;
|
|
120
|
+
try {
|
|
121
|
+
const started = now();
|
|
122
|
+
const device = await gatewayRequest('/device/authorize', { fetchImpl, form: { client_id: CLIENT_ID, resource: RESOURCE, scope: SCOPE, store } });
|
|
123
|
+
if (!safeString(device.device_code) || !/^[A-F0-9]{12}$/.test(device.user_code) || device.verification_uri !== GATEWAY + '/device' || !Number.isInteger(device.expires_in) || device.expires_in <= 0 || device.expires_in > 600 || !Number.isInteger(device.interval) || device.interval < 5 || device.interval > 60) throw new GatewayError('invalid_response');
|
|
124
|
+
output(`Open ${GATEWAY}/device in one browser tab and enter code ${device.user_code}.`);
|
|
125
|
+
output(`Keep using that browser: if needed, use its Install Campaigns link (${GATEWAY}/install), then sign in to ${store}'s dashboard and launch Campaigns. Match the code and explicitly allow reads. Return here afterward. Never paste a dashboard token into the CLI.`);
|
|
126
|
+
const deadline = started + device.expires_in * 1000; let interval = device.interval * 1000;
|
|
127
|
+
for (let attempt = 0; attempt < 120; attempt++) {
|
|
128
|
+
if (now() + interval >= deadline) throw new GatewayError('expired_token');
|
|
129
|
+
await pause(interval);
|
|
130
|
+
const polledAt = now(); if (polledAt >= deadline) throw new GatewayError('expired_token');
|
|
131
|
+
try {
|
|
132
|
+
const data = await gatewayRequest('/token', { fetchImpl, timeoutMs: deadline - polledAt, form: { grant_type: DEVICE_GRANT, client_id: CLIENT_ID, resource: RESOURCE, device_code: device.device_code } });
|
|
133
|
+
// A successful response means the authority issued this grant. Do not
|
|
134
|
+
// abandon it merely because local time crossed the device deadline.
|
|
135
|
+
record = credentialFromResponse(data, store, polledAt); break;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error.status === 401) throw error;
|
|
138
|
+
if (error.code === 'authorization_pending') continue;
|
|
139
|
+
if (error.code === 'slow_down') { interval = Math.min(60000, interval + 5000); continue; }
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!record) throw new GatewayError('expired_token');
|
|
144
|
+
} catch (error) { throw loginFailure(error); }
|
|
145
|
+
await credentials.transaction(binding, storage => {
|
|
146
|
+
const current = storage.read();
|
|
147
|
+
if (JSON.stringify(current) !== JSON.stringify(previous)) throw new Error('Credential storage changed during login. The existing login was preserved; retry.');
|
|
148
|
+
storage.write(record);
|
|
149
|
+
});
|
|
150
|
+
output(`Logged in for ${store}. Gateway credentials saved to your user credential store.`);
|
|
151
|
+
return { ok: true, store, access_expires_at: record.access_expires_at };
|
|
152
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Test fixture: a REAL package install of this checkout, staged under a
|
|
2
2
|
// temporary install root, so a test can run the CLI the way a consumer's
|
|
3
|
-
// `npx campaigns-os …` does (install mode detected from the
|
|
4
|
-
// node_modules, pinned commit read from the lockfile npm would have
|
|
3
|
+
// `npx --no-install campaigns-os …` does (install mode detected from the
|
|
4
|
+
// enclosing node_modules, pinned commit read from the lockfile npm would have
|
|
5
|
+
// written).
|
|
5
6
|
// Shared by the tooling-status and page-kit sync suites; not part of the
|
|
6
7
|
// supported surface.
|
|
7
8
|
import { cpSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|