octwin-cli 0.8.6 → 0.8.8

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.
@@ -17,11 +17,18 @@
17
17
  * checks that were catching real bugs. So the rule is: **report only what is
18
18
  * unambiguous, and walk away from anything else.**
19
19
  *
20
- * It reports exactly four things:
20
+ * It reports exactly five things:
21
21
  * 1. a key not in `properties` where `additionalProperties: false`
22
22
  * 2. a missing `required` key
23
23
  * 3. a scalar whose `type` is plainly wrong
24
24
  * 4. a value outside a closed `enum`
25
+ * 5. a map KEY outside `propertyNames` (`enum` or `pattern`)
26
+ *
27
+ * Rule 5 was added 2026-09-06 and is the reason the RBAC vocabulary is worth publishing at all: a
28
+ * map's keys carry meaning in `roles.yaml` (grant resource keys, verb keys), and reading only
29
+ * VALUES meant an author could not learn about a bad key until `--remote`. It is safe under the
30
+ * false-positive rule above because it is a faithful replay — the same enum membership and the same
31
+ * regex engine the platform's own Zod runs.
25
32
  *
26
33
  * It STOPS DESCENDING (reports nothing at all for that subtree) at any node
27
34
  * carrying `anyOf` / `oneOf` / `allOf` / `not`, at an unresolvable `$ref`, and at
@@ -142,6 +149,48 @@ function walk(value, schema, path, defs, out, file, depth) {
142
149
  const obj = value;
143
150
  const props = isObj(schema.properties) ? schema.properties : undefined;
144
151
  const addl = schema.additionalProperties;
152
+ // 5. KEY vocabulary — `propertyNames` on a map schema.
153
+ //
154
+ // The fifth rule, and the one this file existed without for its whole life. A map's KEYS carry
155
+ // meaning in three declarations (`roles.yaml` grants and verbs, `xrm.yaml` entity names), and the
156
+ // walker read only values — so a pack author following `craft/manifest.md` wrote `case:` where
157
+ // the key is `record.case`, passed offline `validate`, and learned the truth from a `--remote`
158
+ // round-trip that reported one bad guess at a time. They abandoned custom RBAC over it.
159
+ //
160
+ // Safe to report because it is a FAITHFUL REPLAY: the same `enum` membership and the same JS
161
+ // regex engine Zod itself runs, over a pattern the platform generated. A false positive would
162
+ // require the published schema to disagree with the platform that published it.
163
+ const names = isObj(schema.propertyNames) ? schema.propertyNames : undefined;
164
+ if (names) {
165
+ const allowed = Array.isArray(names.enum) ? names.enum : undefined;
166
+ const pattern = typeof names.pattern === 'string' ? names.pattern : undefined;
167
+ let re;
168
+ // A pattern the local engine cannot compile is out of subset, exactly like a combinator —
169
+ // walk away rather than guess. (JSON Schema permits ECMA-262; this IS that engine, so in
170
+ // practice only a future dialect change lands here.)
171
+ if (pattern) {
172
+ try {
173
+ re = new RegExp(pattern);
174
+ }
175
+ catch {
176
+ re = undefined;
177
+ }
178
+ }
179
+ for (const k of Object.keys(obj)) {
180
+ if (allowed && !allowed.includes(k)) {
181
+ out.push({
182
+ file, path: path ? `${path}.${k}` : k,
183
+ message: `not a valid key here — must be one of ${allowed.map(e => JSON.stringify(e)).join(', ')}`,
184
+ });
185
+ }
186
+ else if (re && !re.test(k)) {
187
+ out.push({
188
+ file, path: path ? `${path}.${k}` : k,
189
+ message: `not a valid key here — it must match ${pattern}`,
190
+ });
191
+ }
192
+ }
193
+ }
145
194
  // A map schema (`additionalProperties: <schema>`, no `properties`) — every
146
195
  // value shares one shape. This is how `entities:` and `agents:` are declared.
147
196
  if (!props && isObj(addl)) {
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The pure half of a browser `octwin login` — everything about the device-code
3
+ * handshake that isn't a socket.
4
+ *
5
+ * `index.ts` keeps the URL construction and the actual `fetch` calls (the route
6
+ * guard in `cli-routes.test.ts` reads that file and only that file, so a URL that
7
+ * moved here would stop being checked against the platform's route table). What
8
+ * lives here is the part worth testing without a server: how a poll response is
9
+ * read, how long to wait next, and the on-disk record of a handshake in flight.
10
+ *
11
+ * ## Why a handshake is written to disk at all
12
+ *
13
+ * Because the CLI is mostly driven by an agent, and an agent's shell call is cut
14
+ * off long before a human finishes clicking. So `octwin login` waits a bounded
15
+ * time and then RETURNS, keeping the request; running it again resumes the same
16
+ * one with the same code. Without a file, a second run would mint a second code
17
+ * and the code the human is looking at would be dead.
18
+ */
19
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
20
+ import { dirname } from 'node:path';
21
+ /**
22
+ * `~/.octwin/pending-login.json`, a map of platform url → handshake.
23
+ *
24
+ * Deliberately NOT `credentials.json`: every value in that file is a token, and its
25
+ * one reserved key is safe only because a url always contains `://` and the key
26
+ * never does. A second value shape there would make that argument stop holding.
27
+ */
28
+ export function readPending(path, url) {
29
+ let raw;
30
+ try {
31
+ raw = readFileSync(path, 'utf8');
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ try {
37
+ const map = JSON.parse(raw);
38
+ const found = map[url];
39
+ return found && typeof found.device_code === 'string' ? found : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ } // a corrupt file just means "no handshake in flight"
44
+ }
45
+ export function writePending(path, url, pending) {
46
+ let map = {};
47
+ try {
48
+ map = JSON.parse(readFileSync(path, 'utf8'));
49
+ }
50
+ catch { /* first one */ }
51
+ map[url] = pending;
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ writeFileSync(path, JSON.stringify(map, null, 2), 'utf8');
54
+ }
55
+ export function clearPending(path, url) {
56
+ let map;
57
+ try {
58
+ map = JSON.parse(readFileSync(path, 'utf8'));
59
+ }
60
+ catch {
61
+ return;
62
+ }
63
+ if (!(url in map))
64
+ return;
65
+ delete map[url];
66
+ mkdirSync(dirname(path), { recursive: true });
67
+ writeFileSync(path, JSON.stringify(map, null, 2), 'utf8');
68
+ }
69
+ /** True once the platform would refuse this handshake — checked before reusing one. */
70
+ export function isPendingExpired(pending, now = Date.now()) {
71
+ const at = Date.parse(pending.expires_at);
72
+ return Number.isNaN(at) || at <= now;
73
+ }
74
+ /** Whole seconds left, floored at 0 — for the "expires in 7 min" line. */
75
+ export function secondsLeft(pending, now = Date.now()) {
76
+ const at = Date.parse(pending.expires_at);
77
+ if (Number.isNaN(at))
78
+ return 0;
79
+ return Math.max(0, Math.round((at - now) / 1000));
80
+ }
81
+ /**
82
+ * Read one poll response.
83
+ *
84
+ * A 429 is NOT a failure — the platform's own limiter sets `Retry-After`, and a
85
+ * client that treats being asked to slow down as an error turns a wait into a
86
+ * broken login. 5xx is the same shape for a different reason.
87
+ */
88
+ export function interpretPoll(status, json, retryAfterHeader, defaultDelayMs) {
89
+ if (status === 429 || status >= 500) {
90
+ const secs = Number(retryAfterHeader);
91
+ const afterMs = Number.isFinite(secs) && secs > 0 ? secs * 1000 : defaultDelayMs * 2;
92
+ return { kind: 'retry', afterMs };
93
+ }
94
+ const body = (json ?? {});
95
+ if (status !== 200) {
96
+ return { kind: 'failed', detail: String(body.error ?? `the platform answered HTTP ${status}`) };
97
+ }
98
+ switch (body.status) {
99
+ case 'approved':
100
+ if (typeof body.token !== 'string' || !body.token) {
101
+ return { kind: 'failed', detail: 'the platform approved the login but returned no token' };
102
+ }
103
+ return {
104
+ kind: 'approved',
105
+ token: body.token,
106
+ tenantSlug: typeof body.tenant_slug === 'string' ? body.tenant_slug : '',
107
+ projectSlug: typeof body.project_slug === 'string' ? body.project_slug : null,
108
+ tokenExpires: typeof body.token_expires === 'string' ? body.token_expires : null,
109
+ };
110
+ case 'pending': return { kind: 'pending' };
111
+ case 'expired': return { kind: 'gone', reason: 'expired' };
112
+ case 'not_found': return { kind: 'gone', reason: 'not_found' };
113
+ default:
114
+ return { kind: 'failed', detail: `the platform answered with an unknown status '${String(body.status)}'` };
115
+ }
116
+ }
117
+ /**
118
+ * How this machine introduces itself on the approval screen. Untrusted by the
119
+ * platform (this call carries no credential), so it is a courtesy to the approver,
120
+ * never a claim — which is exactly how the console renders it.
121
+ */
122
+ export function clientLabel(hostname, platform) {
123
+ const os = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' }[platform] ?? platform;
124
+ return `${hostname || 'unknown host'} · ${os}`;
125
+ }
126
+ /** Exit code for "the wait ended, nothing is wrong" — `EX_TEMPFAIL`. An agent branches on it. */
127
+ export const EXIT_STILL_PENDING = 75;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
5
5
  "type": "module",
6
6
  "bin": {