@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.
Files changed (49) hide show
  1. package/AGENTS.md +114 -10
  2. package/CHANGELOG.md +530 -0
  3. package/README.md +38 -27
  4. package/contracts/agent-relevant-change-policy.v1.json +11 -1
  5. package/contracts/effects.v1.json +4794 -0
  6. package/contracts/release-ledger.json +789 -0
  7. package/contracts/supported-surface.json +25 -5
  8. package/docs/build-packet.md +27 -16
  9. package/docs/demo-preview.md +1 -1
  10. package/docs/diagnostics.md +7 -4
  11. package/docs/effects.md +281 -0
  12. package/docs/gateway-login.md +113 -0
  13. package/docs/orientation-contract-reference.md +4 -1
  14. package/docs/progress-snapshots.md +3 -3
  15. package/docs/qa-and-test-orders.md +3 -3
  16. package/docs/readback.md +523 -0
  17. package/docs/runtime-readiness.md +1 -1
  18. package/docs/sdk-storage-compatibility.md +1 -1
  19. package/docs/skills-revision.md +364 -0
  20. package/docs/supported-surface.md +11 -3
  21. package/docs/versioning.md +8 -4
  22. package/package.json +8 -3
  23. package/schemas/campaign-runtime-build-packet.v0.schema.json +5 -0
  24. package/schemas/campaigns-os-effects.v1.schema.json +211 -0
  25. package/schemas/campaigns-os-readback.v2.schema.json +267 -0
  26. package/skills/campaign-lifecycle-orientation/SKILL.md +174 -0
  27. package/skills/campaign-readback-classification/SKILL.md +230 -0
  28. package/skills/campaign-run-evidence/SKILL.md +140 -0
  29. package/skills/contribution-intake/SKILL.md +85 -0
  30. package/skills/next-campaigns-build/SKILL.md +33 -12
  31. package/skills/next-campaigns-os/SKILL.md +45 -21
  32. package/skills/next-campaigns-os-setup/SKILL.md +35 -14
  33. package/skills/next-campaigns-polish/SKILL.md +43 -17
  34. package/skills/next-campaigns-qa/SKILL.md +48 -24
  35. package/skills.json +39 -6
  36. package/src/admin-transport.mjs +123 -0
  37. package/src/cli.mjs +991 -200
  38. package/src/credential-store.mjs +183 -0
  39. package/src/deviation.mjs +3 -2
  40. package/src/diagnostic.mjs +4 -1
  41. package/src/gate-actions.mjs +2 -2
  42. package/src/install-mode.mjs +17 -9
  43. package/src/lifecycle.mjs +95 -0
  44. package/src/login.mjs +152 -0
  45. package/src/package-install-fixture.mjs +3 -2
  46. package/src/qa-node.mjs +56 -19
  47. package/src/qa-publish.mjs +108 -2
  48. package/src/readback.mjs +1936 -0
  49. 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`, `npm run
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
  }
@@ -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
- const PLATFORMS = new Set(["claude", "codex", "agents", "all"]);
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([
@@ -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, `npx campaigns-os`
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
  }
@@ -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 pinned as a
220
- // devDependency of the campaign folder) — always, because `npx` itself puts
221
- // node_modules/.bin on PATH for the duration of the command, so a PATH match
222
- // seen here says nothing about the operator's shell, and a bare command they
223
- // paste there would not resolve; the bare binary from a plain package
224
- // directory. The PATH comparison is still reported so a shadowing install is
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 = "npx campaigns-os";
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 alone.
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 enclosing
4
- // node_modules, pinned commit read from the lockfile npm would have written).
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";