@timekast/factory 1.8.0 → 1.10.0
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/dist/commands/add.js +3 -0
- package/dist/commands/secrets.js +67 -0
- package/dist/commands/update.js +8 -2
- package/dist/index.js +13 -0
- package/dist/lib/env-merge.js +201 -0
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -90,6 +90,9 @@ export async function runAdd(flags = { core: false, full: false }) {
|
|
|
90
90
|
// A legacy `add` redirect is never a beta channel operation.
|
|
91
91
|
beta: false,
|
|
92
92
|
stable: false,
|
|
93
|
+
// `add` is interactive: keep the legacy confirm prompt. Only the GUI/headless
|
|
94
|
+
// caller passes `--yes` (via `update` directly), never this redirect.
|
|
95
|
+
yes: false,
|
|
93
96
|
});
|
|
94
97
|
return;
|
|
95
98
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `factory secrets <file>` — distribute methodology secrets safely (SEC-001).
|
|
3
|
+
*
|
|
4
|
+
* Reads a master `KEY=value` file and merges it idempotently into the
|
|
5
|
+
* per-user secrets rail `~/.claude/.env`, creating it with `chmod 600` if it
|
|
6
|
+
* does not exist. Two hard guarantees:
|
|
7
|
+
*
|
|
8
|
+
* 1. Fail-closed gate — `runPreflight()` (gh installed + authed + org member)
|
|
9
|
+
* runs FIRST, before any I/O on the target. A non-member, an expired
|
|
10
|
+
* session, or `gh` missing from PATH aborts with a non-zero exit and the
|
|
11
|
+
* target is never created or modified. In headless mode with no resolvable
|
|
12
|
+
* auth, preflight throws too → the command is a no-op fail-closed.
|
|
13
|
+
* 2. No-echo — stdout lists only the KEY NAMES processed (added / updated /
|
|
14
|
+
* skipped), never their values. A secret value never reaches the terminal.
|
|
15
|
+
*
|
|
16
|
+
* The org gate is `runPreflight()` from `lib/preflight.ts` — the public API.
|
|
17
|
+
* `preflight()` / `assertOrgMembership` are NOT public and are not called here.
|
|
18
|
+
*/
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { mergeEnvFile } from '../lib/env-merge.js';
|
|
22
|
+
import { runPreflight } from '../lib/preflight.js';
|
|
23
|
+
/** Absolute path to the per-user secrets rail (`~/.claude/.env`). */
|
|
24
|
+
export function defaultSecretsTarget() {
|
|
25
|
+
return path.join(homedir(), '.claude', '.env');
|
|
26
|
+
}
|
|
27
|
+
/** Render the merge outcome to stdout — KEY NAMES only, never values (no-echo). */
|
|
28
|
+
function reportMerge(result, targetPath) {
|
|
29
|
+
// Surface ignored lines first (P2-2): only the candidate name (left of `=`) is
|
|
30
|
+
// shown — the value of an invalid line is never echoed.
|
|
31
|
+
if (result.invalid.length > 0) {
|
|
32
|
+
console.warn(`⚠ Ignoré ${result.invalid.length} línea(s) con nombre de clave inválido: ` +
|
|
33
|
+
`${result.invalid.join(', ')} (solo se permite [A-Za-z_][A-Za-z0-9_]*).`);
|
|
34
|
+
}
|
|
35
|
+
const total = result.added.length + result.updated.length + result.skipped.length;
|
|
36
|
+
if (total === 0) {
|
|
37
|
+
console.log('No había claves que procesar en el archivo fuente.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
console.log(`✔ Secretos sincronizados en \`${targetPath}\` (permisos 600).`);
|
|
41
|
+
if (result.added.length > 0) {
|
|
42
|
+
console.log(` Agregadas: ${result.added.join(', ')}`);
|
|
43
|
+
}
|
|
44
|
+
if (result.updated.length > 0) {
|
|
45
|
+
console.log(` Actualizadas: ${result.updated.join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
if (result.skipped.length > 0) {
|
|
48
|
+
console.log(` Sin cambios: ${result.skipped.join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
// Reaffirm the no-echo guarantee so the operator knows values were never shown.
|
|
51
|
+
console.log('ℹ Solo se muestran NOMBRES de clave; los valores nunca se imprimen.');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Run `factory secrets <file>`. Order is deliberate and security-relevant:
|
|
55
|
+
* preflight gate first (fail-closed — no target I/O on failure), then the merge.
|
|
56
|
+
*/
|
|
57
|
+
export async function secretsCommand(file, deps = {}) {
|
|
58
|
+
const preflight = deps.preflight ?? runPreflight;
|
|
59
|
+
const targetPath = deps.targetPath ?? defaultSecretsTarget();
|
|
60
|
+
// 1. Fail-closed gate. Throws BEFORE any read/write of the source or target —
|
|
61
|
+
// a non-member / unauthenticated / headless-no-token run touches nothing.
|
|
62
|
+
await preflight();
|
|
63
|
+
// 2. Merge the source into the rail (creates it 0600 if absent, idempotent).
|
|
64
|
+
const result = await mergeEnvFile(file, targetPath);
|
|
65
|
+
// 3. Report — KEY NAMES only.
|
|
66
|
+
reportMerge(result, targetPath);
|
|
67
|
+
}
|
package/dist/commands/update.js
CHANGED
|
@@ -62,6 +62,7 @@ export function parseUpdateFlags(argv) {
|
|
|
62
62
|
noCommit: argv.includes('--no-commit'),
|
|
63
63
|
beta: argv.includes('--beta'),
|
|
64
64
|
stable: argv.includes('--stable'),
|
|
65
|
+
yes: argv.includes('--yes') || argv.includes('-y'),
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
/** Default acquire: download the sticky-profile tarball, stage + validate it. */
|
|
@@ -454,7 +455,7 @@ export async function runUpdate(flags, deps = {}) {
|
|
|
454
455
|
if (legacy) {
|
|
455
456
|
profile = flags.full ? PROFILES.full : flags.core ? PROFILES.core : detectProfile(rootDir);
|
|
456
457
|
oldLock = { version: '0.0.0', profile, files: [] };
|
|
457
|
-
await confirmLegacy(profile);
|
|
458
|
+
await confirmLegacy(profile, flags.yes);
|
|
458
459
|
}
|
|
459
460
|
else {
|
|
460
461
|
oldLock = readLockfile(rootDir);
|
|
@@ -576,7 +577,12 @@ async function guardChannelChange(oldLock, flags) {
|
|
|
576
577
|
}
|
|
577
578
|
}
|
|
578
579
|
/** Warn the dev before a legacy auto-register sync (design §8 edge case). */
|
|
579
|
-
async function confirmLegacy(profile) {
|
|
580
|
+
async function confirmLegacy(profile, assumeYes) {
|
|
581
|
+
// `--yes` (GUI launcher / headless): skip the interactive confirm. The caller
|
|
582
|
+
// already surfaced the overwrite warning (the app shows it as a native dialog).
|
|
583
|
+
// An interactive terminal run without `--yes` still gets the prompt below.
|
|
584
|
+
if (assumeYes)
|
|
585
|
+
return;
|
|
580
586
|
const detected = profile === PROFILES.full
|
|
581
587
|
? 'Detectado como derivado del Factory (`factoryVersion` en package.json) → perfil `full` ' +
|
|
582
588
|
'(incluye sk-* + SK.md). Usa `--core` si quieres solo el cerebro base.'
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { parseAddFlags, runAdd } from './commands/add.js';
|
|
|
14
14
|
import { runDoctor } from './commands/doctor.js';
|
|
15
15
|
import { parseNewFlags, runNew } from './commands/new.js';
|
|
16
16
|
import { parsePublishArgs, runPublish } from './commands/publish.js';
|
|
17
|
+
import { secretsCommand } from './commands/secrets.js';
|
|
17
18
|
import { runStatus } from './commands/status.js';
|
|
18
19
|
import { parseUnpublishTargets, runUnpublish } from './commands/unpublish.js';
|
|
19
20
|
import { parseUpdateFlags, runUpdate } from './commands/update.js';
|
|
@@ -32,6 +33,7 @@ Comandos:
|
|
|
32
33
|
doctor Detecta huérfanos, conflictos y rules sin importar + aviso de seguridad
|
|
33
34
|
publish <qué> Publica el entregable (proposal|mockup|both) a proposals.timekast.mx
|
|
34
35
|
unpublish [qué] Baja del hub un entregable publicado (proposal|mockup|both; default both)
|
|
36
|
+
secrets <file> Mergea un archivo KEY=value de secretos de metodología a ~/.claude/.env (chmod 600)
|
|
35
37
|
|
|
36
38
|
Sobre \`new\`:
|
|
37
39
|
Pregunta el perfil de forma interactiva. Los flags \`--full\` / \`--core\` lo
|
|
@@ -59,6 +61,8 @@ Sobre \`update\`:
|
|
|
59
61
|
--verify Reporta diferencias disco-vs-lockfile sin modificar nada
|
|
60
62
|
--beta Entra al canal beta (instala el último pre-release \`-beta.N\`)
|
|
61
63
|
--stable Sale del canal beta y baja al estable (limpia \`beta\` del lockfile)
|
|
64
|
+
--yes / -y Auto-confirma el auto-registro de un repo legacy (sin lockfile) —
|
|
65
|
+
para lanzadores GUI / headless; en terminal interactiva sigue preguntando
|
|
62
66
|
Un \`update\` sin \`--beta\` sobre un lockfile beta pide confirmación (y en headless
|
|
63
67
|
rechaza, sin degradar en silencio).
|
|
64
68
|
|
|
@@ -134,6 +138,15 @@ async function main(argv) {
|
|
|
134
138
|
await runUnpublish(parseUnpublishTargets(rest));
|
|
135
139
|
return;
|
|
136
140
|
}
|
|
141
|
+
case 'secrets': {
|
|
142
|
+
const file = rest.find((arg) => !arg.startsWith('-'));
|
|
143
|
+
if (!file) {
|
|
144
|
+
throw new CLIError('Falta el archivo de secretos. Uso: `factory secrets <file>` ' +
|
|
145
|
+
'(un archivo `KEY=value`, p.ej. el master `secrets.env`).');
|
|
146
|
+
}
|
|
147
|
+
await secretsCommand(file);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
137
150
|
default:
|
|
138
151
|
throw new CLIError(`Comando desconocido: \`${command}\`.\nEjecuta \`factory --help\` para ver los comandos disponibles.`);
|
|
139
152
|
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-naive `.env` parser + merger for the `factory secrets` rail (SEC-001).
|
|
3
|
+
*
|
|
4
|
+
* Merges a SOURCE `.env`-style file into a TARGET (`~/.claude/.env` in
|
|
5
|
+
* production), idempotently and additively:
|
|
6
|
+
* - keys in source that are absent in target → ADDED
|
|
7
|
+
* - keys in source that already exist with a different value → UPDATED
|
|
8
|
+
* - keys in source whose value already matches target → SKIPPED (no churn)
|
|
9
|
+
* - keys in target NOT mentioned in source → preserved verbatim
|
|
10
|
+
* - comment lines (`# ...`) + blank lines in target → preserved verbatim
|
|
11
|
+
* - the target's trailing newline → preserved
|
|
12
|
+
* - target absent → created with `chmod 600` BEFORE the first write
|
|
13
|
+
*
|
|
14
|
+
* "Non-naive" parsing rules (the sharp cases the tests pin):
|
|
15
|
+
* - split a `KEY=value` line on the FIRST `=` only — a value may contain `=`
|
|
16
|
+
* (`KEY=a=b=c` → value `a=b=c`).
|
|
17
|
+
* - a value wrapped in single or double quotes keeps its quotes literally
|
|
18
|
+
* (`KEY="val"` and `KEY='val'` round-trip byte-for-byte).
|
|
19
|
+
* - a `#` INSIDE a quoted value is literal, NOT a comment start
|
|
20
|
+
* (`KEY="val#hash"` keeps `#hash`). A `#` at column 0 (after optional
|
|
21
|
+
* whitespace) is a comment line and is never parsed as a key.
|
|
22
|
+
*
|
|
23
|
+
* The merge is line-oriented and surgical: an updated key is rewritten in place
|
|
24
|
+
* (its original line position is kept), so re-running with the same source is a
|
|
25
|
+
* no-op on disk (idempotent — every key SKIPs the second time).
|
|
26
|
+
*
|
|
27
|
+
* Known limitation (documented, not blocked — SEC-001 §8): concurrent writers
|
|
28
|
+
* on the same target may corrupt it (no file lock). The command is manual.
|
|
29
|
+
*/
|
|
30
|
+
import { chmodSync, existsSync, lstatSync, readFileSync, writeFileSync } from 'node:fs';
|
|
31
|
+
/**
|
|
32
|
+
* True when a line is a comment or blank — never parsed as a key. A comment is
|
|
33
|
+
* a `#` at the first non-whitespace column; a blank line is empty/whitespace.
|
|
34
|
+
*/
|
|
35
|
+
function isCommentOrBlank(line) {
|
|
36
|
+
const trimmed = line.trimStart();
|
|
37
|
+
return trimmed === '' || trimmed.startsWith('#');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A valid env-var name: a letter or underscore, then letters/digits/underscores
|
|
41
|
+
* (POSIX shell name shape). The source file is the one fully-untrusted input the
|
|
42
|
+
* tool otherwise trusts, so a line whose key fails this shape (`export FOO`,
|
|
43
|
+
* `; rm -rf`, `FOO BAR`) is NOT written to the rail — it's reported as invalid
|
|
44
|
+
* (P2-2 hardening). Inert as `.env` data, but we refuse to write surprising keys.
|
|
45
|
+
*/
|
|
46
|
+
function isValidKey(key) {
|
|
47
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parse a single `KEY=value` line, splitting on the FIRST `=` only. Returns null
|
|
51
|
+
* for comment/blank lines, lines with no `=`, and lines whose key is not a valid
|
|
52
|
+
* env-var name (all preserved as-is by the target rewrite, never treated as a
|
|
53
|
+
* key). The key is trimmed; the value keeps its quotes and inner characters
|
|
54
|
+
* literally (only trailing whitespace, which a real `.env` loader ignores, is
|
|
55
|
+
* stripped so re-runs stay idempotent).
|
|
56
|
+
*/
|
|
57
|
+
function parseLine(line) {
|
|
58
|
+
if (isCommentOrBlank(line))
|
|
59
|
+
return null;
|
|
60
|
+
const eq = line.indexOf('=');
|
|
61
|
+
if (eq === -1)
|
|
62
|
+
return null;
|
|
63
|
+
const key = line.slice(0, eq).trim();
|
|
64
|
+
if (key === '')
|
|
65
|
+
return null;
|
|
66
|
+
if (!isValidKey(key))
|
|
67
|
+
return null;
|
|
68
|
+
const value = line.slice(eq + 1).replace(/\s+$/, '');
|
|
69
|
+
return { key, value };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build the canonical `KEY=value` line for a pair. Centralized so source parse
|
|
73
|
+
* and target rewrite emit identical text (the idempotency contract depends on
|
|
74
|
+
* byte-equality between a freshly written line and the one parsed back).
|
|
75
|
+
*/
|
|
76
|
+
function renderLine(pair) {
|
|
77
|
+
return `${pair.key}=${pair.value}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Read `.env` text, failing closed with a clear error on non-UTF-8 input (SEC-001
|
|
81
|
+
* §8 edge case). `readFileSync(..., 'utf8')` does not throw on invalid bytes — it
|
|
82
|
+
* substitutes U+FFFD — so we detect the replacement character and reject rather
|
|
83
|
+
* than silently corrupt a secret. An empty/missing target is the caller's
|
|
84
|
+
* concern; this only runs on files known to exist.
|
|
85
|
+
*/
|
|
86
|
+
function readEnvText(filePath) {
|
|
87
|
+
const text = readFileSync(filePath, 'utf8');
|
|
88
|
+
if (text.includes('�')) {
|
|
89
|
+
throw new Error(`El archivo \`${filePath}\` no es UTF-8 válido (bytes no decodificables). ` +
|
|
90
|
+
'Revisa su codificación; no se modificó nada.');
|
|
91
|
+
}
|
|
92
|
+
return text;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Parse the SOURCE file's key/value pairs, last-write-wins on duplicate keys (a
|
|
96
|
+
* source listing a key twice resolves to its final value — mirrors how a real
|
|
97
|
+
* loader reads top-to-bottom). Order of first appearance is preserved for the
|
|
98
|
+
* keys that get appended to the target. Lines whose key fails `isValidKey` are
|
|
99
|
+
* NOT merged — their candidate name (value-free) is collected in `invalid` so
|
|
100
|
+
* the command can warn the operator (P2-2).
|
|
101
|
+
*/
|
|
102
|
+
function parseSource(text) {
|
|
103
|
+
const byKey = new Map();
|
|
104
|
+
const order = [];
|
|
105
|
+
const invalid = [];
|
|
106
|
+
for (const line of text.split('\n')) {
|
|
107
|
+
if (isCommentOrBlank(line))
|
|
108
|
+
continue;
|
|
109
|
+
const eq = line.indexOf('=');
|
|
110
|
+
if (eq === -1)
|
|
111
|
+
continue; // no '=' — not a key assignment; ignored in a source file
|
|
112
|
+
const key = line.slice(0, eq).trim();
|
|
113
|
+
if (key === '')
|
|
114
|
+
continue;
|
|
115
|
+
if (!isValidKey(key)) {
|
|
116
|
+
if (!invalid.includes(key))
|
|
117
|
+
invalid.push(key); // candidate only — never the value
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const value = line.slice(eq + 1).replace(/\s+$/, '');
|
|
121
|
+
if (!byKey.has(key))
|
|
122
|
+
order.push(key);
|
|
123
|
+
byKey.set(key, value);
|
|
124
|
+
}
|
|
125
|
+
return { pairs: order.map((key) => ({ key, value: byKey.get(key) })), invalid };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Merge `sourcePath` into `targetPath`. See the module docstring for the full
|
|
129
|
+
* contract. Async to match the CLI's command surface (`mergeEnvFile(...)` is
|
|
130
|
+
* awaited), though the FS ops are synchronous under the hood — the cost is a
|
|
131
|
+
* couple of small reads/writes, and sync keeps the chmod-before-write ordering
|
|
132
|
+
* race-free.
|
|
133
|
+
*
|
|
134
|
+
* @throws when the source is missing/unreadable, or either file is not UTF-8.
|
|
135
|
+
*/
|
|
136
|
+
export async function mergeEnvFile(sourcePath, targetPath) {
|
|
137
|
+
// Source must exist and be readable — fail closed with a clear message (the
|
|
138
|
+
// command never half-writes a target off a missing/garbled source).
|
|
139
|
+
if (!existsSync(sourcePath)) {
|
|
140
|
+
throw new Error(`No encontré el archivo fuente \`${sourcePath}\`.`);
|
|
141
|
+
}
|
|
142
|
+
const { pairs: sourcePairs, invalid } = parseSource(readEnvText(sourcePath));
|
|
143
|
+
// Reject a symlink target explicitly (SEC-001 §8): writing through a symlink
|
|
144
|
+
// could redirect secrets outside `~/.claude/`. We refuse rather than follow.
|
|
145
|
+
if (existsSync(targetPath) && lstatSync(targetPath).isSymbolicLink()) {
|
|
146
|
+
throw new Error(`El destino \`${targetPath}\` es un symlink. Por seguridad no escribo a través de symlinks; ` +
|
|
147
|
+
'apunta a un archivo regular.');
|
|
148
|
+
}
|
|
149
|
+
// Target absent → create empty with 0600 BEFORE writing any secret, so the
|
|
150
|
+
// file is never world-readable even for the instant between create and chmod.
|
|
151
|
+
const targetExists = existsSync(targetPath);
|
|
152
|
+
if (!targetExists) {
|
|
153
|
+
writeFileSync(targetPath, '', { mode: 0o600 });
|
|
154
|
+
}
|
|
155
|
+
const targetText = targetExists ? readEnvText(targetPath) : '';
|
|
156
|
+
// Preserve the trailing-newline shape of the original target: an empty file and
|
|
157
|
+
// a file ending in `\n` both round-trip; a file without a final newline gets one
|
|
158
|
+
// (a well-formed `.env` ends in a newline — POSIX text-file convention).
|
|
159
|
+
const hadTrailingNewline = targetText.endsWith('\n');
|
|
160
|
+
const lines = targetText.length === 0 ? [] : targetText.replace(/\n$/, '').split('\n');
|
|
161
|
+
const sourceByKey = new Map(sourcePairs.map((p) => [p.key, p.value]));
|
|
162
|
+
const seenInTarget = new Set();
|
|
163
|
+
const added = [];
|
|
164
|
+
const updated = [];
|
|
165
|
+
const skipped = [];
|
|
166
|
+
// Rewrite existing target lines in place: a key present in source is updated
|
|
167
|
+
// (or skipped if unchanged); comments, blanks, and untouched keys pass through.
|
|
168
|
+
const rewritten = lines.map((line) => {
|
|
169
|
+
const pair = parseLine(line);
|
|
170
|
+
if (!pair)
|
|
171
|
+
return line; // comment / blank / malformed — preserve verbatim
|
|
172
|
+
seenInTarget.add(pair.key);
|
|
173
|
+
if (!sourceByKey.has(pair.key))
|
|
174
|
+
return line; // key not in source — preserve
|
|
175
|
+
const nextValue = sourceByKey.get(pair.key);
|
|
176
|
+
if (nextValue === pair.value) {
|
|
177
|
+
skipped.push(pair.key);
|
|
178
|
+
return line;
|
|
179
|
+
}
|
|
180
|
+
updated.push(pair.key);
|
|
181
|
+
return renderLine({ key: pair.key, value: nextValue });
|
|
182
|
+
});
|
|
183
|
+
// Append source keys that were not already in the target, in source order.
|
|
184
|
+
for (const pair of sourcePairs) {
|
|
185
|
+
if (seenInTarget.has(pair.key))
|
|
186
|
+
continue;
|
|
187
|
+
added.push(pair.key);
|
|
188
|
+
rewritten.push(renderLine(pair));
|
|
189
|
+
}
|
|
190
|
+
// Reassemble. Restore the trailing newline when the original had one (or when
|
|
191
|
+
// we created/append-grew the file — a `.env` should end in `\n`).
|
|
192
|
+
const body = rewritten.join('\n');
|
|
193
|
+
const shouldEndWithNewline = hadTrailingNewline || !targetExists || rewritten.length > lines.length;
|
|
194
|
+
const finalText = shouldEndWithNewline && rewritten.length > 0 ? `${body}\n` : body;
|
|
195
|
+
writeFileSync(targetPath, finalText, { mode: 0o600 });
|
|
196
|
+
// Re-assert 0600 even when the file already existed (it may have been created
|
|
197
|
+
// with looser perms before this rail existed — SEC-001 §8). `writeFileSync`'s
|
|
198
|
+
// `mode` is ignored for an existing file, so chmod explicitly.
|
|
199
|
+
chmodSync(targetPath, 0o600);
|
|
200
|
+
return { added, updated, skipped, invalid };
|
|
201
|
+
}
|