suitescript-verify 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eric Nemchik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # suitescript-verify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run probes in a NetSuite **sandbox** to verify what SuiteScript modules do, from a CLI that is safe to hand to an agent: sandbox only, a person approves every write at a terminal, and output is scrubbed of identifiers.
4
+
5
+ > This package never accepts Oracle's SuiteCloud license. Install `@oracle/suitecloud-cli` in your project and accept the license yourself.
6
+
7
+ ```sh
8
+ npm install --save-dev suitescript-verify @oracle/suitecloud-cli
9
+ npx suitescript-verify help
10
+ ```
11
+
12
+ `init` scaffolds `verification/probes`, `verification/sdf`, `docs/verification/results` and a committed `verification/.env` of layout keys, then prints a to-do list. It creates no credentials. `doctor` checks the SuiteCloud CLI, account, sandbox, certificates, passkey and project files, and says which failing steps are the maintainer's own; it exits 1 while any fails.
13
+
14
+ `certificate` generates the runner key pair (EC P-256) and the SDF key pair (RSA-4096) outside the project with `openssl` (`--keys-dir`, default `~/.suitescript-verify`), `passkey` and `env set NAME=value` save settings in the gitignored `verification/.env.local` (values are never printed; a different existing value is kept unless `--overwrite`), and `setup-ci` registers the SDF credentials with the SuiteCloud CLI.
15
+
16
+ `setup-ci` prepares a pipeline: it registers the SDF credentials (auth id `suitescript-verify`) with your project's SuiteCloud CLI and passes the passkey to it through the environment, never an argument. On success it prints the `SUITESCRIPT_VERIFY_` names the pipeline must supply as its own secrets (account, client id, both certificate ids, both private key paths, passkey), never a value. It writes no file, no credential variable and no license variable, and it holds no credential itself: the pipeline provides them. Accepting Oracle's SuiteCloud license in the pipeline is yours.
17
+
18
+ `deploy` generates the runner and an SDF project into `verification/sdf` (the generated `src/` and `package.json` there are yours to gitignore) from the RESTlet probes in `verification/probes`, then dry-runs it through your project's SuiteCloud CLI. A real deploy needs `--approved` and a person at a terminal who types `DEPLOY`. `cleanup` does the same with the runner's deployment switched off and needs `CLEANUP`. `--dir <path>` moves `verification/`.
19
+
20
+ `run <probe> [--draft]` runs one probe by name through the deployed runner. The name must be one of the probes in the folder: the runner only runs the fixed list generated into it and never code from a request. Output is scrubbed of the account, client and certificate ids, and refused if it still looks like an identifier. A probe with an `<probe>.expect.json` is compared with it and a difference exits 2 as drift. `--draft` writes `docs/verification/results/<probe>.md` (never over an existing page) with the test, NetSuite version and results, and empty explanation and decision sections. Probes must declare `@scriptType Restlet` and no `@writes`.
21
+
22
+ A probe takes data by declaring parameters in its leading JSDoc block, one tag per parameter: `@param <name> <pattern>`. The name is lowercase-initial letters and digits (`recordType`); the pattern is a JavaScript regular expression (Unicode mode) that the whole value must match, so `[a-z_]+` cannot match `a;b`. Pass each value with `run <probe> --param name=value`. Every declared parameter is required and its value is always a string. The runner validates the parameters against the declarations before any request, rejecting an invalid, undeclared, missing or repeated one, and a probe that declares none takes none; the generated runner checks them again and hands the probe only declared strings in `run(params)`. A value goes in the JSON request body and is never evaluated or turned into code. A bad `@param` tag (no pattern, an invalid pattern, a duplicate name) stops `run` and `deploy`.
23
+
24
+ The package validates the tags in a probe's leading JSDoc block: `@scriptType`, `@writes`, `@param`, the NetSuite `@NApiVersion`, `@NScriptType` and `@NModuleScope`, and the ordinary `@see`, `@link`, `@example`, `@returns`, `@since` and `@deprecated`. Any other tag is reported and stops `run` and `deploy`, so a typo cannot pass silently. A project that keeps its own tags registers them with `SUITESCRIPT_VERIFY_EXTRA_TAGS`, a comma-separated list of tag names without the `@` (for example `SUITESCRIPT_VERIFY_EXTRA_TAGS=flavor,other`). Like every `SUITESCRIPT_VERIFY_` setting it layers from the committed `verification/.env`, the gitignored `.env.local` and the process environment. In the committed file the value must be a list of tag names and must not look like a secret. The package checks only that a registered tag is known; what a tag means is up to the project.
25
+
26
+ ```js
27
+ /**
28
+ * @scriptType Restlet
29
+ * @param recordType ^[a-z_]+$
30
+ */
31
+ define(["N/record"], (record) => ({
32
+ run: ({ recordType }) => /* ... */,
33
+ }));
34
+ ```
35
+
36
+ Every command that reaches the sandbox refuses an account that is not a sandbox (`digits_SBn`). The runner uses the script id `customscript_suitescript_verify_runner` and is limited to a role with the ID `customrole_suitescript_verify`, which you create in the sandbox.
37
+
38
+ See [docs/verification/README.md](docs/verification/README.md) and [docs/verification/lessons.md](docs/verification/lessons.md).
39
+
40
+ ## Development
41
+
42
+ You need the Node.js and npm versions in `package.json` `devEngines`.
43
+
44
+ ```sh
45
+ npm install
46
+ npm run skills:install # optional: restores the agent skills for Claude Code
47
+ npm test # builds, then runs the CLI tests against a fake SuiteCloud CLI
48
+ npm run format # type-checks, then applies Biome's fixes
49
+ ```
50
+
51
+ Git hooks (lefthook) run Biome on staged files before each commit.
52
+
53
+ ## License
54
+
55
+ MIT
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../cli.js";
3
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,116 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join, relative, resolve } from "node:path";
5
+ import { names } from "./doctor.js";
6
+ import { allowReplace, flagValue, saveEnv, UsageError } from "./shared.js";
7
+ /** Oracle's documented `openssl` commands: ES256 (EC P-256) and RSA-PSS (RSA-4096, PS256). */
8
+ function generatePair(options) {
9
+ const keyArgs = options.kind === "ec"
10
+ ? [
11
+ "-newkey",
12
+ "ec",
13
+ "-pkeyopt",
14
+ "ec_paramgen_curve:prime256v1",
15
+ "-pkeyopt",
16
+ "ec_param_enc:named_curve",
17
+ ]
18
+ : [
19
+ "-newkey",
20
+ "rsa:4096",
21
+ "-sigopt",
22
+ "rsa_padding_mode:pss",
23
+ "-sha256",
24
+ "-sigopt",
25
+ "rsa_pss_saltlen:64",
26
+ ];
27
+ const result = spawnSync(options.openssl, [
28
+ "req",
29
+ "-new",
30
+ "-x509",
31
+ ...keyArgs,
32
+ "-nodes",
33
+ "-days",
34
+ "365",
35
+ "-subj",
36
+ "/CN=suitescript-verify",
37
+ "-out",
38
+ options.publicKey,
39
+ "-keyout",
40
+ options.privateKey,
41
+ ], { encoding: "utf8" });
42
+ if (result.status !== 0) {
43
+ throw new UsageError("openssl failed to generate the key pair.");
44
+ }
45
+ try {
46
+ chmodSync(options.privateKey, 0o600);
47
+ }
48
+ catch {
49
+ // Windows ignores POSIX modes.
50
+ }
51
+ if (process.platform === "win32" && process.env.USERNAME) {
52
+ spawnSync("icacls", [
53
+ options.privateKey,
54
+ "/inheritance:r",
55
+ "/grant:r",
56
+ `${process.env.USERNAME}:F`,
57
+ ]);
58
+ }
59
+ }
60
+ /** Generates the runner (EC P-256) and SDF (RSA-4096) key pairs outside the project and saves their paths in .env.local. */
61
+ export async function certificate(project, config, args) {
62
+ const dir = resolve(flagValue(args, "--keys-dir") ?? join(homedir(), ".suitescript-verify"));
63
+ const rel = relative(project, dir);
64
+ if (rel === "" || !(rel.startsWith("..") || isAbsolute(rel))) {
65
+ throw new UsageError("Choose a --keys-dir outside the project.");
66
+ }
67
+ const openssl = [
68
+ "openssl",
69
+ ...(process.platform === "win32"
70
+ ? ["C:\\Program Files\\Git\\usr\\bin\\openssl.exe"]
71
+ : []),
72
+ ].find((candidate) => spawnSync(candidate, ["version"], { encoding: "utf8" }).status === 0);
73
+ if (!openssl) {
74
+ throw new UsageError("openssl was not found. Generate the key pairs by hand with Oracle's documented openssl commands.");
75
+ }
76
+ // The runner token is signed here (ES256, EC P-256). Oracle's SDK signs the
77
+ // SDF login with PS256 and an RSA key only, so that pair is RSA-4096.
78
+ const kinds = { runner: "ec", sdf: "rsa" };
79
+ const only = flagValue(args, "--only");
80
+ if (only !== undefined && only !== "runner" && only !== "sdf") {
81
+ throw new UsageError("--only takes runner or sdf.");
82
+ }
83
+ const which = ["runner", "sdf"].filter((name) => only === undefined || only === name);
84
+ const paths = (name) => ({
85
+ privateKey: join(dir, `${name}-private.pem`),
86
+ publicKey: join(dir, `${name}-public.pem`),
87
+ });
88
+ mkdirSync(dir, { mode: 0o700, recursive: true });
89
+ for (const name of which) {
90
+ const pair = paths(name);
91
+ const exists = existsSync(pair.privateKey) || existsSync(pair.publicKey);
92
+ const mapped = config.values[name === "runner" ? names.certificateId : names.sdfCertificateId];
93
+ if (exists && mapped) {
94
+ console.log(`Warning: a ${name} certificate id is already saved. A mapping cannot be recreated with the same certificate, and replacing this pair makes that mapping unusable (its private key is lost).`);
95
+ }
96
+ if (!exists || (await allowReplace(args, `The ${name} key pair`))) {
97
+ generatePair({ kind: kinds[name], openssl, ...pair });
98
+ console.log(`Generated the ${name} key pair (${kinds[name] === "ec" ? "EC P-256" : "RSA-4096, RSA-PSS certificate"}, valid for 365 days).`);
99
+ }
100
+ else
101
+ console.log(`Kept the existing ${name} key pair.`);
102
+ }
103
+ await saveEnv(config, args, {
104
+ ...(which.includes("runner")
105
+ ? { [names.privateKeyPath]: paths("runner").privateKey }
106
+ : {}),
107
+ ...(which.includes("sdf")
108
+ ? { [names.sdfPrivateKeyPath]: paths("sdf").privateKey }
109
+ : {}),
110
+ });
111
+ console.log([
112
+ "",
113
+ "Upload these public certificates in your sandbox (Setup > Integration > Manage Authentication > OAuth 2.0 Client Credentials (M2M) Setup):",
114
+ ...which.map((name) => ` ${name}: ${paths(name).publicKey}`),
115
+ ].join("\n"));
116
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,196 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { certificate } from "./certificate.js";
3
+ import { loadConfig } from "./config.js";
4
+ import { formatChecks, names, runChecks } from "./doctor.js";
5
+ import { envNames } from "./envfile.js";
6
+ import { init } from "./init.js";
7
+ import { runProbe } from "./run.js";
8
+ import { buildSdfProject } from "./sdf.js";
9
+ import { confirmWrite, flagValue, positionals, requireSandbox, saveEnv, suitecloud, UsageError, } from "./shared.js";
10
+ export const USAGE = `Usage: suitescript-verify <command> [options]
11
+
12
+ Commands:
13
+ init Scaffold the folders and .env, and print a to-do list
14
+ doctor Check the setup; each failing step says who does it
15
+ certificate [--only runner|sdf] [--keys-dir <path>] [--overwrite | --keep]
16
+ Generate the key pairs outside the project
17
+ passkey [--overwrite | --keep] Generate the SuiteCloud passkey into .env.local
18
+ env set NAME=value [--overwrite | --keep]
19
+ Save a setting in .env.local
20
+ setup-ci Register the SDF credentials (account:setup:ci)
21
+ deploy [--approved] Dry-run the runner deployment; --approved deploys, from your own terminal
22
+ run <probe> [--param name=value ...] [--draft] Run one RESTlet probe through the runner; --draft writes a results page
23
+ cleanup [--approved] Turn the runner's deployment off (dry run without --approved)
24
+ help Show this help
25
+
26
+ Every command takes --dir <path> to move the verification folder (default verification).
27
+
28
+ Probes run in a NetSuite sandbox only. The package never accepts Oracle's SuiteCloud license:
29
+ install @oracle/suitecloud-cli in your project and accept the license yourself.`;
30
+ const dirFlag = (args) => flagValue(args, "--dir");
31
+ /** Loads the layered configuration; a problem stops every command except the reporting ones. */
32
+ function configure(args, refuse) {
33
+ const config = loadConfig(process.cwd(), dirFlag(args));
34
+ if (refuse && config.problems.length > 0) {
35
+ throw new UsageError(`Refusing to run:\n${config.problems.map((p) => `- ${p.message}`).join("\n")}`);
36
+ }
37
+ return config;
38
+ }
39
+ /** Loads the configuration for `doctor` and `init`, which report problems instead of refusing. */
40
+ function reporting(args) {
41
+ const config = configure(args, false);
42
+ if (config.problems.length > 0) {
43
+ console.log("Configuration problems:");
44
+ for (const problem of config.problems)
45
+ console.log(`- ${problem.message}`);
46
+ console.log("");
47
+ }
48
+ return config;
49
+ }
50
+ function doctor(args) {
51
+ const config = reporting(args);
52
+ const checks = runChecks(process.cwd(), config);
53
+ console.log(formatChecks(checks));
54
+ return config.problems.length === 0 && checks.every((c) => c.ok) ? 0 : 1;
55
+ }
56
+ function initCommand(args) {
57
+ console.log(init(process.cwd(), reporting(args)));
58
+ return 0;
59
+ }
60
+ async function deploy(args) {
61
+ const project = process.cwd();
62
+ const config = configure(args, true);
63
+ requireSandbox(config);
64
+ const { files, slugs } = buildSdfProject(config.sdfDir, config.probesDir, {
65
+ deployed: true,
66
+ extraTags: config.extraTags,
67
+ });
68
+ console.log(`Probes in the runner: ${slugs.join(", ") || "(none)"}`);
69
+ console.log(`Files to upload: ${files.join(", ")}`);
70
+ const approved = args.includes("--approved");
71
+ if (approved)
72
+ await confirmWrite("DEPLOY", "This deploys to the sandbox");
73
+ else {
74
+ console.log("\nDry run only. Deploying writes to the sandbox, so ask the user for approval, then rerun with --approved.");
75
+ }
76
+ return suitecloud(project, config, [
77
+ "project:deploy",
78
+ ...(approved ? [] : ["--dryrun"]),
79
+ ]);
80
+ }
81
+ async function cleanup(args) {
82
+ const project = process.cwd();
83
+ const config = configure(args, true);
84
+ requireSandbox(config);
85
+ const approved = args.includes("--approved");
86
+ if (approved) {
87
+ await confirmWrite("CLEANUP", "This turns the runner's deployment off");
88
+ }
89
+ else {
90
+ console.log("Dry run only. This turns the runner's deployment off in the sandbox, so ask the user for approval, then rerun with --approved.\nTo remove access entirely, the user revokes the certificate mapping (Setup > Integration > Manage Authentication > OAuth 2.0 Client Credentials (M2M) Setup).");
91
+ }
92
+ buildSdfProject(config.sdfDir, config.probesDir, {
93
+ deployed: false,
94
+ extraTags: config.extraTags,
95
+ });
96
+ return suitecloud(project, config, [
97
+ "project:deploy",
98
+ ...(approved ? [] : ["--dryrun"]),
99
+ ]);
100
+ }
101
+ const CI_SETTINGS = [
102
+ "ACCOUNT",
103
+ "CLIENT_ID",
104
+ "CERTIFICATE_ID",
105
+ "PRIVATE_KEY_PATH",
106
+ "SDF_CERTIFICATE_ID",
107
+ "SDF_PRIVATE_KEY_PATH",
108
+ "PASSKEY",
109
+ ].map((name) => ` SUITESCRIPT_VERIFY_${name}`);
110
+ const CI_NOTES = [
111
+ "",
112
+ "Registered the SDF credentials with the SuiteCloud CLI. To run probes from a pipeline, the pipeline supplies these settings as its own secrets and variables (names only; this command wrote none and holds no value):",
113
+ ...CI_SETTINGS,
114
+ "Accept Oracle's SuiteCloud license yourself in the pipeline; this package never does.",
115
+ ].join("\n");
116
+ function setupCi(args) {
117
+ const config = configure(args, true);
118
+ const creds = requireSandbox(config);
119
+ const status = suitecloud(process.cwd(), config, [
120
+ "account:setup:ci",
121
+ "--account",
122
+ creds.account,
123
+ "--authid",
124
+ "suitescript-verify",
125
+ "--certificateid",
126
+ creds.sdfCertificateId,
127
+ "--privatekeypath",
128
+ creds.sdfPrivateKeyPath,
129
+ ...(config.values.SUITESCRIPT_VERIFY_DOMAIN
130
+ ? ["--domain", config.values.SUITESCRIPT_VERIFY_DOMAIN]
131
+ : []),
132
+ ]);
133
+ if (status === 0)
134
+ console.log(CI_NOTES);
135
+ return status;
136
+ }
137
+ async function envCommand(args) {
138
+ const config = configure(args, true);
139
+ const [action, assignment] = positionals(args);
140
+ const match = /^([A-Z0-9_]+)=([\s\S]*)$/.exec(assignment ?? "");
141
+ if (action !== "set" || !match) {
142
+ throw new UsageError("Usage: suitescript-verify env set NAME=value [--overwrite | --keep]");
143
+ }
144
+ const name = match[1] ?? "";
145
+ if (name === names.passkey) {
146
+ throw new UsageError("Generate the passkey with `suitescript-verify passkey`.");
147
+ }
148
+ if (!envNames.includes(name)) {
149
+ throw new UsageError(`Unknown variable ${name}. Known: ${envNames.join(", ")}`);
150
+ }
151
+ await saveEnv(config, args, { [name]: match[2] ?? "" });
152
+ return 0;
153
+ }
154
+ export async function main(args) {
155
+ const [command, ...rest] = args;
156
+ try {
157
+ if (command === undefined || command === "help" || command === "--help") {
158
+ console.log(USAGE);
159
+ return 0;
160
+ }
161
+ if (command === "deploy")
162
+ return await deploy(rest);
163
+ if (command === "cleanup")
164
+ return await cleanup(rest);
165
+ if (command === "run") {
166
+ const config = configure(rest, true);
167
+ return await runProbe(process.cwd(), config, rest);
168
+ }
169
+ if (command === "certificate") {
170
+ await certificate(process.cwd(), configure(rest, true), rest);
171
+ return 0;
172
+ }
173
+ if (command === "passkey") {
174
+ await saveEnv(configure(rest, true), rest, {
175
+ [names.passkey]: randomBytes(30).toString("hex"),
176
+ });
177
+ return 0;
178
+ }
179
+ if (command === "env")
180
+ return await envCommand(rest);
181
+ if (command === "setup-ci")
182
+ return setupCi(rest);
183
+ if (command === "doctor")
184
+ return doctor(rest);
185
+ if (command === "init")
186
+ return initCommand(rest);
187
+ throw new UsageError(`Unknown command "${command}".\n\n${USAGE}`);
188
+ }
189
+ catch (error) {
190
+ if (error instanceof UsageError) {
191
+ console.error(error.message);
192
+ return 1;
193
+ }
194
+ throw error;
195
+ }
196
+ }
package/dist/config.js ADDED
@@ -0,0 +1,141 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ const prefix = "SUITESCRIPT_VERIFY_";
5
+ /** Layout keys: the only ones the committed `.env` may hold. Each is a path in the project. */
6
+ export const layoutKeys = [
7
+ "SUITESCRIPT_VERIFY_PROBES_DIR",
8
+ "SUITESCRIPT_VERIFY_RESULTS_DIR",
9
+ "SUITESCRIPT_VERIFY_SDF_DIR",
10
+ ];
11
+ /** The committed `.env` may also register extra probe tags: a comma-separated list of tag names, not a path. */
12
+ export const extraTagsKey = "SUITESCRIPT_VERIFY_EXTRA_TAGS";
13
+ const tagName = /^[A-Za-z][A-Za-z0-9_-]*$/;
14
+ /** The tag names in a registration value, without the `@`; blank entries are dropped. */
15
+ export const parseTagList = (value) => value
16
+ .split(",")
17
+ .map((name) => name.trim().replace(/^@/, ""))
18
+ .filter((name) => name !== "");
19
+ const unquote = (raw) => {
20
+ const value = raw.trim();
21
+ const quote = value[0];
22
+ if (value.length >= 2 &&
23
+ (quote === '"' || quote === "'") &&
24
+ value.endsWith(quote)) {
25
+ return value.slice(1, -1);
26
+ }
27
+ return value;
28
+ };
29
+ /** Reads `NAME=value` lines; comments and other lines are ignored. */
30
+ export function parseEnv(text) {
31
+ const values = new Map();
32
+ for (const line of text.split(/\r?\n/)) {
33
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
34
+ if (match)
35
+ values.set(match[1] ?? "", unquote(match[2] ?? ""));
36
+ }
37
+ return values;
38
+ }
39
+ function readEnvFile(path) {
40
+ return existsSync(path)
41
+ ? parseEnv(readFileSync(path, "utf8"))
42
+ : new Map();
43
+ }
44
+ const insideProject = (project, path) => {
45
+ const rel = relative(project, resolve(project, path));
46
+ return !isAbsolute(rel) && rel.split(/[\\/]/)[0] !== "..";
47
+ };
48
+ /** A value a person would not write as a folder: a PEM block, a JWT or a long token. */
49
+ const looksSecret = (value) => /-----BEGIN /.test(value) ||
50
+ /^eyJ[\w-]+\.[\w-]+\.[\w-]+$/.test(value) ||
51
+ /^[A-Za-z0-9+_=-]{32,}$/.test(value);
52
+ /** Checks the committed `.env` against the allowlist. Messages name the key, never the value. */
53
+ function checkCommitted(project, committed, file) {
54
+ const problems = [];
55
+ for (const [key, value] of committed) {
56
+ if (key === extraTagsKey) {
57
+ if (!parseTagList(value).every((name) => tagName.test(name))) {
58
+ problems.push({
59
+ message: `${key} in ${file} is not a comma-separated list of tag names like flavor,other.`,
60
+ });
61
+ }
62
+ else if (looksSecret(value)) {
63
+ problems.push({
64
+ message: `${key} in ${file} looks like a secret, not a list of tag names. The committed file is public: move a secret to .env.local.`,
65
+ });
66
+ }
67
+ }
68
+ else if (!layoutKeys.includes(key)) {
69
+ problems.push({
70
+ message: `${key} in ${file} is not a layout key. The committed file holds layout only (${[...layoutKeys, extraTagsKey].join(", ")}); put anything else in .env.local or the process environment.`,
71
+ });
72
+ }
73
+ else if (!insideProject(project, value)) {
74
+ problems.push({
75
+ message: `${key} in ${file} points outside the project. Use a path inside the project.`,
76
+ });
77
+ }
78
+ else if (looksSecret(value)) {
79
+ problems.push({
80
+ message: `${key} in ${file} looks like a secret, not a path. The committed file is public: move a secret to .env.local.`,
81
+ });
82
+ }
83
+ }
84
+ return problems;
85
+ }
86
+ /** `.env.local` holds credentials: it must be gitignored and never tracked. */
87
+ function checkLocalIgnored(project, localFile) {
88
+ const git = (args) => spawnSync("git", args, { cwd: project, encoding: "utf8" });
89
+ const rel = relative(project, localFile).replaceAll("\\", "/");
90
+ if (git(["rev-parse", "--is-inside-work-tree"]).status !== 0)
91
+ return [];
92
+ const problems = [];
93
+ if (git(["ls-files", "--error-unmatch", "--", rel]).status === 0) {
94
+ problems.push({
95
+ message: `${rel} is tracked by git, so its credentials are in the repository. Run git rm --cached ${rel} and rotate what it held.`,
96
+ });
97
+ }
98
+ if (git(["check-ignore", "-q", "--", rel]).status !== 0) {
99
+ problems.push({
100
+ message: `${rel} is not gitignored. Add it to .gitignore before saving any value: it holds credentials.`,
101
+ });
102
+ }
103
+ return problems;
104
+ }
105
+ /**
106
+ * Layers defaults, the committed `.env`, the gitignored `.env.local` and the
107
+ * process environment (later wins). The root of the env files is `dir`
108
+ * (default `verification`), relative to the project.
109
+ */
110
+ export function loadConfig(project, dir, processEnv = process.env) {
111
+ const root = resolve(project, dir ?? "verification");
112
+ const envFile = join(root, ".env");
113
+ const localFile = join(root, ".env.local");
114
+ const committed = readEnvFile(envFile);
115
+ const values = {};
116
+ const layers = [
117
+ committed,
118
+ readEnvFile(localFile),
119
+ Object.entries(processEnv),
120
+ ];
121
+ for (const layer of layers) {
122
+ for (const [key, value] of layer) {
123
+ if (key.startsWith(prefix) && value !== undefined)
124
+ values[key] = value;
125
+ }
126
+ }
127
+ const path = (key, fallback) => resolve(project, values[key] || fallback);
128
+ const shown = relative(project, envFile).replaceAll("\\", "/");
129
+ return {
130
+ extraTags: parseTagList(values[extraTagsKey] ?? ""),
131
+ probesDir: path("SUITESCRIPT_VERIFY_PROBES_DIR", join(root, "probes")),
132
+ problems: [
133
+ ...checkCommitted(project, committed, shown),
134
+ ...checkLocalIgnored(project, localFile),
135
+ ],
136
+ resultsDir: path("SUITESCRIPT_VERIFY_RESULTS_DIR", "docs/verification/results"),
137
+ root,
138
+ sdfDir: path("SUITESCRIPT_VERIFY_SDF_DIR", join(root, "sdf")),
139
+ values,
140
+ };
141
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,97 @@
1
+ import { createPrivateKey } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export const names = {
5
+ account: "SUITESCRIPT_VERIFY_ACCOUNT",
6
+ certificateId: "SUITESCRIPT_VERIFY_CERTIFICATE_ID",
7
+ clientId: "SUITESCRIPT_VERIFY_CLIENT_ID",
8
+ passkey: "SUITESCRIPT_VERIFY_PASSKEY",
9
+ privateKeyPath: "SUITESCRIPT_VERIFY_PRIVATE_KEY_PATH",
10
+ sdfCertificateId: "SUITESCRIPT_VERIFY_SDF_CERTIFICATE_ID",
11
+ sdfPrivateKeyPath: "SUITESCRIPT_VERIFY_SDF_PRIVATE_KEY_PATH",
12
+ };
13
+ /** `ec:<curve>`, `rsa:<bits>` or `other`, from a private key file. Never the key itself. */
14
+ function keyType(path) {
15
+ try {
16
+ const key = createPrivateKey(readFileSync(path));
17
+ const details = key.asymmetricKeyDetails;
18
+ if (key.asymmetricKeyType === "ec")
19
+ return `ec:${details?.namedCurve}`;
20
+ if (key.asymmetricKeyType === "rsa")
21
+ return `rsa:${details?.modulusLength}`;
22
+ }
23
+ catch {
24
+ // Unreadable or not a private key.
25
+ }
26
+ return "other";
27
+ }
28
+ /** A sandbox account id looks like `1234567_SB1`. Verification never runs in production. */
29
+ export const isSandboxAccount = (account) => /^\d{5,8}_SB\d+$/i.test(account);
30
+ /** Read-only checklist: each failing step says what to do next and who does it. */
31
+ export function runChecks(project, config, env = process.env) {
32
+ const checks = [];
33
+ const add = (name, ok, owner, next) => checks.push({ name, next, ok, owner });
34
+ const v = config.values;
35
+ add("SuiteCloud CLI (@oracle/suitecloud-cli) is installed in the project", existsSync(join(project, "node_modules", "@oracle", "suitecloud-cli", "src", "suitecloud.js")), "maintainer", "Run npm install --save-dev @oracle/suitecloud-cli, read Oracle's license and accept it yourself. This package never accepts it.");
36
+ const missing = [
37
+ names.account,
38
+ names.clientId,
39
+ names.certificateId,
40
+ names.sdfCertificateId,
41
+ ].filter((name) => !v[name]);
42
+ add("Account, client id and both certificate ids are set", missing.length === 0, "maintainer", `Missing: ${missing.join(", ")}. Create the integration record and mapping in your sandbox, then save the values in .env.local (never the committed .env).`);
43
+ add("The account is a sandbox", isSandboxAccount(v[names.account] ?? ""), "maintainer", `${names.account} must look like digits_SBn. Verification never runs in production.`);
44
+ for (const [label, key, expected, accepts] of [
45
+ [
46
+ "runner",
47
+ names.privateKeyPath,
48
+ "an EC P-256 key",
49
+ (t) => t === "ec:prime256v1",
50
+ ],
51
+ [
52
+ "SDF",
53
+ names.sdfPrivateKeyPath,
54
+ "an RSA key of 3072 or 4096 bits",
55
+ (t) => t === "rsa:3072" || t === "rsa:4096",
56
+ ],
57
+ ]) {
58
+ const path = v[key] ?? "";
59
+ const exists = path !== "" && existsSync(path);
60
+ add(`The ${label} private key exists (${key})`, exists, "maintainer", `Generate the key pair outside the repository and set ${key} in .env.local.`);
61
+ if (exists) {
62
+ add(`The ${label} private key is ${expected}`, accepts(keyType(path)), "maintainer", "Generate a key of that type and upload its new public certificate in a new mapping.");
63
+ }
64
+ }
65
+ const passkey = v[names.passkey] ?? env.SUITECLOUD_CI_PASSKEY ?? "";
66
+ add(`${names.passkey} (or SUITECLOUD_CI_PASSKEY) is set, 32 to 100 letters and digits`, /^[A-Za-z0-9]{32,100}$/.test(passkey), "maintainer", `Generate a random passkey and save it as ${names.passkey} in .env.local. Keep a copy somewhere safe: it encrypts the stored credentials. Never set it user-wide.`);
67
+ add("SUITECLOUD_FALLBACK_PASSKEY is not set", !env.SUITECLOUD_FALLBACK_PASSKEY, "maintainer", "Unset it: the CLI refuses to run with both passkeys.");
68
+ const projectJson = join(config.sdfDir, "project.json");
69
+ let authId = "";
70
+ if (existsSync(projectJson)) {
71
+ try {
72
+ authId =
73
+ JSON.parse(readFileSync(projectJson, "utf8")).defaultAuthId ?? "";
74
+ }
75
+ catch {
76
+ // A malformed project.json fails the step.
77
+ }
78
+ }
79
+ const absent = [config.probesDir, config.resultsDir, config.sdfDir].filter((dir) => !existsSync(dir));
80
+ add("Project files: the probe, results and SDF folders exist and the SDF project has a default auth id", absent.length === 0 && authId !== "", absent.length > 0 ? "agent" : "maintainer", absent.length > 0
81
+ ? "Run suitescript-verify init to create the missing folders."
82
+ : "Run account:setup:ci from the SDF folder once the steps above pass, so project.json gets a default auth id.");
83
+ return checks;
84
+ }
85
+ export function formatChecks(checks) {
86
+ const lines = checks.map((c) => c.ok
87
+ ? `ok ${c.name}`
88
+ : `FAIL ${c.name}\n ${c.owner === "maintainer" ? "Maintainer's own step" : "Agent can do this"}: ${c.next ?? ""}`);
89
+ const failed = checks.filter((c) => !c.ok);
90
+ if (failed.length === 0)
91
+ lines.push("\nAll checks passed.");
92
+ else {
93
+ const own = failed.filter((c) => c.owner === "maintainer").length;
94
+ lines.push(`\n${failed.length} check(s) failed.${own > 0 ? ` ${own} are the Maintainer's own: relay each next step to the maintainer and wait. Never accept the license, create credentials or touch an account for them.` : ""}`);
95
+ }
96
+ return lines.join("\n");
97
+ }