@shipdit/cli 0.4.0 → 0.5.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/README.md +21 -0
- package/dist/index.mjs +340 -43
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,3 +12,24 @@ shipd types gen
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
`shipd login` stores a scoped management API key. Use `SHIPD_API_KEY` for CI — keys carry org/project/environment metadata.
|
|
15
|
+
|
|
16
|
+
## Go projects
|
|
17
|
+
|
|
18
|
+
The CLI reads `shipd.config.go` directly. Declare an exported
|
|
19
|
+
`var Config = config.ShipdConfig{...}` using
|
|
20
|
+
`github.com/alasti-company/useshipd.com/sdks/go/config`. Go 1.23+ and Node.js 22.12+
|
|
21
|
+
are needed for CLI commands; the application runtime only needs Go.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
shipd diff
|
|
25
|
+
shipd push
|
|
26
|
+
shipd types gen
|
|
27
|
+
shipd pull
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Go input automatically selects Go output in `internal/flags/flags_gen.go`.
|
|
31
|
+
Generation emits per-flag accessors, key and variant constants, and `Defaults()`.
|
|
32
|
+
Pull writes Go definitions back to `shipd.config.go`. No JSON flag file is needed.
|
|
33
|
+
The definition file is evaluated on its own inside the Go module: imports and
|
|
34
|
+
helpers declared in that file work; sibling files are not loaded. Do not define
|
|
35
|
+
`main` in it. See `examples/go-examples` for a complete example.
|
package/dist/index.mjs
CHANGED
|
@@ -5,8 +5,30 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { createJiti } from "jiti";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
8
9
|
|
|
10
|
+
//#region src/lib/errors.ts
|
|
11
|
+
var CliError = class extends Error {
|
|
12
|
+
code;
|
|
13
|
+
exitCode;
|
|
14
|
+
details;
|
|
15
|
+
constructor(code, message, options) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "CliError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.exitCode = options?.exitCode ?? 1;
|
|
20
|
+
this.details = options?.details;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
function exitCodeForError(error) {
|
|
24
|
+
if (error instanceof CliError) return error.exitCode;
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
9
29
|
//#region src/lib/config.ts
|
|
30
|
+
/** Hosted control plane. Used when no flag, env, project, global, or credential URL is set. */
|
|
31
|
+
const DEFAULT_API_URL = "https://api.useshipd.com";
|
|
10
32
|
function readJsonFile(filePath) {
|
|
11
33
|
if (!fs.existsSync(filePath)) return null;
|
|
12
34
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
@@ -42,7 +64,7 @@ function clearCredentials() {
|
|
|
42
64
|
function findProjectRoot(start = process.cwd()) {
|
|
43
65
|
let current = path.resolve(start);
|
|
44
66
|
while (true) {
|
|
45
|
-
if (fs.existsSync(path.join(current, "shipd.config.ts")) || fs.existsSync(path.join(current, ".shipd", "config.json"))) return current;
|
|
67
|
+
if (fs.existsSync(path.join(current, "shipd.config.ts")) || fs.existsSync(path.join(current, "shipd.config.go")) || fs.existsSync(path.join(current, "shipd.config.json")) || fs.existsSync(path.join(current, ".shipd", "config.json"))) return current;
|
|
46
68
|
const parent = path.dirname(current);
|
|
47
69
|
if (parent === current) return process.cwd();
|
|
48
70
|
current = parent;
|
|
@@ -58,16 +80,25 @@ function resolveRuntimeConfig(flags, options) {
|
|
|
58
80
|
const globalConfig = loadGlobalConfig();
|
|
59
81
|
const credentials = loadCredentials();
|
|
60
82
|
const ci = flags.ci === true || flags.yes === true || process.env.CI === "1" || process.env.CI === "true" || process.env.SHIPD_CI === "1";
|
|
61
|
-
const defaultConfigFile =
|
|
83
|
+
const defaultConfigFile = path.join(projectRoot, [
|
|
84
|
+
"shipd.config.ts",
|
|
85
|
+
"shipd.config.go",
|
|
86
|
+
"shipd.config.json"
|
|
87
|
+
].find((file) => fs.existsSync(path.join(projectRoot, file))) ?? "shipd.config.ts");
|
|
88
|
+
const configFile = flags.flagsConfig ? path.resolve(flags.flagsConfig) : projectConfig.configFile ?? defaultConfigFile;
|
|
89
|
+
const typesLanguage = projectConfig.typesLanguage ?? (path.extname(configFile) === ".go" ? "go" : "typescript");
|
|
90
|
+
if (typesLanguage !== "typescript" && typesLanguage !== "go") throw new CliError("validation_error", "typesLanguage must be typescript or go");
|
|
62
91
|
return {
|
|
63
|
-
apiUrl: flags.apiUrl ?? process.env.SHIPD_API_URL ?? projectConfig.apiUrl ?? globalConfig.apiUrl ?? credentials.apiUrl ?? options?.defaultApiUrl ?? "
|
|
92
|
+
apiUrl: flags.apiUrl ?? process.env.SHIPD_API_URL ?? projectConfig.apiUrl ?? globalConfig.apiUrl ?? credentials.apiUrl ?? options?.defaultApiUrl ?? "https://api.useshipd.com",
|
|
64
93
|
appUrl: process.env.SHIPD_APP_URL ?? projectConfig.appUrl ?? globalConfig.appUrl ?? "http://localhost:5173",
|
|
65
94
|
apiKey: process.env.SHIPD_API_KEY ?? credentials.apiKey,
|
|
66
95
|
org: flags.org ?? process.env.SHIPD_ORG ?? projectConfig.org ?? globalConfig.org,
|
|
67
96
|
project: flags.project ?? process.env.SHIPD_PROJECT ?? projectConfig.project ?? globalConfig.project,
|
|
68
97
|
env: flags.env ?? process.env.SHIPD_ENV ?? projectConfig.env ?? globalConfig.env,
|
|
69
|
-
configFile
|
|
70
|
-
typesOut: projectConfig.typesOut ?? "src/generated/flags.ts",
|
|
98
|
+
configFile,
|
|
99
|
+
typesOut: projectConfig.typesOut ?? (typesLanguage === "go" ? "internal/flags/flags_gen.go" : "src/generated/flags.ts"),
|
|
100
|
+
typesLanguage,
|
|
101
|
+
typesPackage: projectConfig.typesPackage ?? "flags",
|
|
71
102
|
yes: ci,
|
|
72
103
|
json: flags.json === true,
|
|
73
104
|
cwd,
|
|
@@ -4198,25 +4229,6 @@ const f$1 = (e) => createAuthClient({
|
|
|
4198
4229
|
plugins: d$1(e)
|
|
4199
4230
|
});
|
|
4200
4231
|
|
|
4201
|
-
//#endregion
|
|
4202
|
-
//#region src/lib/errors.ts
|
|
4203
|
-
var CliError = class extends Error {
|
|
4204
|
-
code;
|
|
4205
|
-
exitCode;
|
|
4206
|
-
details;
|
|
4207
|
-
constructor(code, message, options) {
|
|
4208
|
-
super(message);
|
|
4209
|
-
this.name = "CliError";
|
|
4210
|
-
this.code = code;
|
|
4211
|
-
this.exitCode = options?.exitCode ?? 1;
|
|
4212
|
-
this.details = options?.details;
|
|
4213
|
-
}
|
|
4214
|
-
};
|
|
4215
|
-
function exitCodeForError(error) {
|
|
4216
|
-
if (error instanceof CliError) return error.exitCode;
|
|
4217
|
-
return 1;
|
|
4218
|
-
}
|
|
4219
|
-
|
|
4220
4232
|
//#endregion
|
|
4221
4233
|
//#region src/lib/client.ts
|
|
4222
4234
|
const AUTH_BASE_PATH = "/api/v1/auth";
|
|
@@ -4384,7 +4396,6 @@ function e(e) {
|
|
|
4384
4396
|
//#region src/commands/auth.ts
|
|
4385
4397
|
const CLIENT_ID = "shipd-cli";
|
|
4386
4398
|
const MANAGEMENT_KEY_PREFIX = "shipd_mgmt_";
|
|
4387
|
-
const DEFAULT_API_URL = "https://api.useshipd.com";
|
|
4388
4399
|
function registerAuthCommands(program) {
|
|
4389
4400
|
program.command("login").description("Authenticate via browser device authorization").addOption(new Option("--api-url <url>", `API origin (default: ${DEFAULT_API_URL})`).env("SHIPD_API_URL")).action(async (_opts, command) => {
|
|
4390
4401
|
const runtime = getRuntime(command, { defaultApiUrl: DEFAULT_API_URL });
|
|
@@ -4620,6 +4631,7 @@ const w = z.object({
|
|
|
4620
4631
|
});
|
|
4621
4632
|
});
|
|
4622
4633
|
const T = z.object({
|
|
4634
|
+
expectedVersion: z.number().int().positive().optional(),
|
|
4623
4635
|
flagId: z.string().min(1),
|
|
4624
4636
|
key: o.optional(),
|
|
4625
4637
|
name: z.string().min(1).optional(),
|
|
@@ -4632,6 +4644,7 @@ const E = z.object({ flagId: z.string().min(1) });
|
|
|
4632
4644
|
const D = z.object({ projectId: z.string().min(1) });
|
|
4633
4645
|
const O = z.object({ flagId: z.string().min(1) });
|
|
4634
4646
|
const k = z.object({
|
|
4647
|
+
expectedVersion: z.number().int().positive().optional(),
|
|
4635
4648
|
flagId: z.string().min(1),
|
|
4636
4649
|
environmentId: z.string().min(1),
|
|
4637
4650
|
enabled: z.boolean().optional(),
|
|
@@ -4639,6 +4652,7 @@ const k = z.object({
|
|
|
4639
4652
|
fallthrough: u.optional()
|
|
4640
4653
|
});
|
|
4641
4654
|
const A = z.object({
|
|
4655
|
+
expectedVersion: z.number().int().positive().optional(),
|
|
4642
4656
|
flagId: z.string().min(1),
|
|
4643
4657
|
environmentId: z.string().min(1),
|
|
4644
4658
|
enabled: z.boolean()
|
|
@@ -4784,6 +4798,188 @@ const J = z.object({
|
|
|
4784
4798
|
approvalRequests: z.array(z.string()).default([])
|
|
4785
4799
|
});
|
|
4786
4800
|
|
|
4801
|
+
//#endregion
|
|
4802
|
+
//#region src/lib/go-string.ts
|
|
4803
|
+
/** Go strings cannot represent JavaScript's unpaired UTF-16 surrogates. */
|
|
4804
|
+
function goString(value) {
|
|
4805
|
+
for (const character of value) {
|
|
4806
|
+
const code = character.charCodeAt(0);
|
|
4807
|
+
if (character.length === 1 && code >= 55296 && code <= 57343) throw new CliError("validation_error", `Cannot generate Go for a string containing an unpaired Unicode surrogate: ${JSON.stringify(value)}`);
|
|
4808
|
+
}
|
|
4809
|
+
return JSON.stringify(value);
|
|
4810
|
+
}
|
|
4811
|
+
|
|
4812
|
+
//#endregion
|
|
4813
|
+
//#region src/lib/go-config.ts
|
|
4814
|
+
/** Read the package clause after leading whitespace and comments, not comment text. */
|
|
4815
|
+
function goPackageClause(source) {
|
|
4816
|
+
let offset = 0;
|
|
4817
|
+
const skipTrivia = () => {
|
|
4818
|
+
while (offset < source.length) {
|
|
4819
|
+
const trivia = /^(?:\s+|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)/u.exec(source.slice(offset));
|
|
4820
|
+
if (!trivia) break;
|
|
4821
|
+
offset += trivia[0].length;
|
|
4822
|
+
}
|
|
4823
|
+
};
|
|
4824
|
+
skipTrivia();
|
|
4825
|
+
if (!/^package\b/u.test(source.slice(offset))) throw new Error("Go config must have a package declaration");
|
|
4826
|
+
offset += 7;
|
|
4827
|
+
skipTrivia();
|
|
4828
|
+
const name = /^[_\p{L}][_\p{L}\p{Nd}]*/u.exec(source.slice(offset))?.[0];
|
|
4829
|
+
if (!name) throw new Error("Go config must have a package name");
|
|
4830
|
+
return {
|
|
4831
|
+
name,
|
|
4832
|
+
start: offset,
|
|
4833
|
+
end: offset + name.length
|
|
4834
|
+
};
|
|
4835
|
+
}
|
|
4836
|
+
/** Evaluate a self-contained Go definition file within its own module. */
|
|
4837
|
+
async function loadGoConfig(file) {
|
|
4838
|
+
const source = fs.readFileSync(file, "utf8");
|
|
4839
|
+
const clause = goPackageClause(source);
|
|
4840
|
+
const directory = fs.mkdtempSync(path.join(path.dirname(file), ".shipd-go-"));
|
|
4841
|
+
const cleanup = () => fs.rmSync(directory, {
|
|
4842
|
+
recursive: true,
|
|
4843
|
+
force: true
|
|
4844
|
+
});
|
|
4845
|
+
process.once("exit", cleanup);
|
|
4846
|
+
let interrupted;
|
|
4847
|
+
let terminate;
|
|
4848
|
+
const interrupt = () => {
|
|
4849
|
+
interrupted = 130;
|
|
4850
|
+
terminate?.();
|
|
4851
|
+
};
|
|
4852
|
+
const shutdown = () => {
|
|
4853
|
+
interrupted = 143;
|
|
4854
|
+
terminate?.();
|
|
4855
|
+
};
|
|
4856
|
+
process.once("SIGINT", interrupt);
|
|
4857
|
+
process.once("SIGTERM", shutdown);
|
|
4858
|
+
try {
|
|
4859
|
+
fs.writeFileSync(path.join(directory, ".gitignore"), "*\n");
|
|
4860
|
+
fs.writeFileSync(path.join(directory, "definitions.go"), source.slice(0, clause.start) + "main" + source.slice(clause.end));
|
|
4861
|
+
fs.writeFileSync(path.join(directory, "main.go"), `package main
|
|
4862
|
+
import (
|
|
4863
|
+
"encoding/json"
|
|
4864
|
+
"fmt"
|
|
4865
|
+
"os"
|
|
4866
|
+
)
|
|
4867
|
+
func main() {
|
|
4868
|
+
file, err := os.Create(os.Args[1])
|
|
4869
|
+
if err == nil {
|
|
4870
|
+
err = json.NewEncoder(file).Encode(Config)
|
|
4871
|
+
if closeErr := file.Close(); err == nil { err = closeErr }
|
|
4872
|
+
}
|
|
4873
|
+
if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
|
|
4874
|
+
}
|
|
4875
|
+
`);
|
|
4876
|
+
const output = path.join(directory, "config.json");
|
|
4877
|
+
try {
|
|
4878
|
+
await new Promise((resolve, reject) => {
|
|
4879
|
+
const grouped = process.platform !== "win32";
|
|
4880
|
+
const child = spawn("go", [
|
|
4881
|
+
"run",
|
|
4882
|
+
".",
|
|
4883
|
+
output
|
|
4884
|
+
], {
|
|
4885
|
+
cwd: directory,
|
|
4886
|
+
detached: grouped,
|
|
4887
|
+
stdio: [
|
|
4888
|
+
"ignore",
|
|
4889
|
+
"ignore",
|
|
4890
|
+
"pipe"
|
|
4891
|
+
]
|
|
4892
|
+
});
|
|
4893
|
+
let stderr = "";
|
|
4894
|
+
let failure;
|
|
4895
|
+
const kill = (signal) => {
|
|
4896
|
+
if (!child.pid) return;
|
|
4897
|
+
try {
|
|
4898
|
+
if (grouped) process.kill(-child.pid, signal);
|
|
4899
|
+
else child.kill(signal);
|
|
4900
|
+
} catch (error) {
|
|
4901
|
+
if (error.code !== "ESRCH") throw error;
|
|
4902
|
+
}
|
|
4903
|
+
};
|
|
4904
|
+
let killTimer;
|
|
4905
|
+
terminate = () => {
|
|
4906
|
+
kill("SIGTERM");
|
|
4907
|
+
killTimer = setTimeout(() => kill("SIGKILL"), 1e3);
|
|
4908
|
+
};
|
|
4909
|
+
const timer = setTimeout(() => {
|
|
4910
|
+
failure = /* @__PURE__ */ new Error("Go config evaluation timed out");
|
|
4911
|
+
kill("SIGKILL");
|
|
4912
|
+
}, 12e4);
|
|
4913
|
+
child.stderr.on("data", (chunk) => {
|
|
4914
|
+
stderr += chunk.toString();
|
|
4915
|
+
if (stderr.length > 8388608) {
|
|
4916
|
+
failure = /* @__PURE__ */ new Error("Go compiler output exceeds 8 MiB");
|
|
4917
|
+
kill("SIGKILL");
|
|
4918
|
+
}
|
|
4919
|
+
});
|
|
4920
|
+
child.once("error", (error) => {
|
|
4921
|
+
failure = error;
|
|
4922
|
+
});
|
|
4923
|
+
child.once("close", (code) => {
|
|
4924
|
+
clearTimeout(timer);
|
|
4925
|
+
clearTimeout(killTimer);
|
|
4926
|
+
terminate = void 0;
|
|
4927
|
+
if (failure || code !== 0) reject(Object.assign(failure ?? /* @__PURE__ */ new Error(`go run exited with code ${code}`), { stderr }));
|
|
4928
|
+
else resolve();
|
|
4929
|
+
});
|
|
4930
|
+
});
|
|
4931
|
+
} catch (error) {
|
|
4932
|
+
throw goRunError(error, path.basename(file));
|
|
4933
|
+
}
|
|
4934
|
+
return JSON.parse(fs.readFileSync(output, "utf8"));
|
|
4935
|
+
} finally {
|
|
4936
|
+
process.removeListener("SIGINT", interrupt);
|
|
4937
|
+
process.removeListener("SIGTERM", shutdown);
|
|
4938
|
+
process.removeListener("exit", cleanup);
|
|
4939
|
+
cleanup();
|
|
4940
|
+
if (interrupted !== void 0) process.exit(interrupted);
|
|
4941
|
+
}
|
|
4942
|
+
}
|
|
4943
|
+
function goRunError(error, configName) {
|
|
4944
|
+
if (error.code === "ENOENT") return /* @__PURE__ */ new Error("Go 1.23+ is required to load Go flag definitions, but `go` was not found on PATH. Install Go from https://go.dev/dl and try again.");
|
|
4945
|
+
const stderr = String(error.stderr ?? "").replace(/(?:\.\/)?definitions\.go/g, configName).trim();
|
|
4946
|
+
const hint = /\bundefined:/.test(stderr) ? `\n${configName} must be self-contained: the CLI compiles only this file, not sibling Go files. Move helper declarations into it or import them from another package.` : "";
|
|
4947
|
+
return new Error(stderr ? `go run failed:\n${stderr}${hint}` : String(error));
|
|
4948
|
+
}
|
|
4949
|
+
function goNumber(value) {
|
|
4950
|
+
const literal = JSON.stringify(value);
|
|
4951
|
+
return Number.isSafeInteger(value) || /[.eE]/.test(literal) ? literal : `${literal}.0`;
|
|
4952
|
+
}
|
|
4953
|
+
function goValue(value) {
|
|
4954
|
+
if (value === null) return "nil";
|
|
4955
|
+
if (typeof value === "number") return goNumber(value);
|
|
4956
|
+
if (typeof value === "string") return goString(value);
|
|
4957
|
+
if (Array.isArray(value)) return `[]any{${value.map(goValue).join(", ")}}`;
|
|
4958
|
+
if (typeof value === "object") return `map[string]any{${Object.entries(value).map(([key, entry]) => `${goString(key)}: ${goValue(entry)}`).join(", ")}}`;
|
|
4959
|
+
return JSON.stringify(value);
|
|
4960
|
+
}
|
|
4961
|
+
/** Emits gofmt-clean Go: one block per flag, field values aligned as gofmt does. */
|
|
4962
|
+
function generateGoConfigSource(flags, packageName = "main") {
|
|
4963
|
+
const entries = flags.map((flag) => {
|
|
4964
|
+
const fields = [["Type", goString(flag.type)], ["Default", goValue(flag.default)]];
|
|
4965
|
+
if (flag.variants) fields.push(["Variants", `[]string{${flag.variants.map((v) => goString(v)).join(", ")}}`]);
|
|
4966
|
+
if (flag.description !== void 0) fields.push(["Description", goString(flag.description)]);
|
|
4967
|
+
if (flag.tags.length) fields.push(["Tags", `[]string{${flag.tags.map((v) => goString(v)).join(", ")}}`]);
|
|
4968
|
+
if (flag.lifecycle === "deprecated") fields.push(["Deprecated", "true"]);
|
|
4969
|
+
if (flag.disabled) fields.push(["Disabled", "true"]);
|
|
4970
|
+
const width = Math.max(...fields.map(([name]) => name.length)) + 1;
|
|
4971
|
+
const lines = fields.map(([name, value]) => `\t\t${`${name}:`.padEnd(width)} ${value},`);
|
|
4972
|
+
return `\t${goString(flag.key)}: {\n${lines.join("\n")}\n\t},`;
|
|
4973
|
+
});
|
|
4974
|
+
return `// Generated by shipd pull. Pull replaces this file; review changes before committing.
|
|
4975
|
+
package ${packageName}
|
|
4976
|
+
|
|
4977
|
+
import "github.com/alasti-company/useshipd.com/sdks/go/config"
|
|
4978
|
+
|
|
4979
|
+
var Config = config.ShipdConfig${entries.length ? `{\n${entries.join("\n")}\n}` : "{}"}
|
|
4980
|
+
`;
|
|
4981
|
+
}
|
|
4982
|
+
|
|
4787
4983
|
//#endregion
|
|
4788
4984
|
//#region src/lib/local-config.ts
|
|
4789
4985
|
const localFlagsArraySchema = z.array(R).superRefine((flags, ctx) => {
|
|
@@ -4800,23 +4996,32 @@ const localFlagsArraySchema = z.array(R).superRefine((flags, ctx) => {
|
|
|
4800
4996
|
async function loadLocalFlags(configFile) {
|
|
4801
4997
|
const absolute = path.resolve(configFile);
|
|
4802
4998
|
if (!fs.existsSync(absolute)) throw new CliError("not_found", `Config file not found: ${absolute}`);
|
|
4803
|
-
|
|
4804
|
-
let loaded;
|
|
4999
|
+
let config;
|
|
4805
5000
|
try {
|
|
4806
|
-
|
|
5001
|
+
if (path.extname(absolute) === ".json") {
|
|
5002
|
+
config = JSON.parse(fs.readFileSync(absolute, "utf8"));
|
|
5003
|
+
if (config?.version !== 1) throw new Error("JSON flag config must have version: 1");
|
|
5004
|
+
} else if (path.extname(absolute) === ".go") config = await loadGoConfig(absolute);
|
|
5005
|
+
else {
|
|
5006
|
+
const loaded = await createJiti(import.meta.url, { interopDefault: true }).import(absolute);
|
|
5007
|
+
config = loaded.default ?? loaded;
|
|
5008
|
+
}
|
|
4807
5009
|
} catch (error) {
|
|
4808
5010
|
const message = error instanceof Error ? error.message : String(error);
|
|
4809
5011
|
throw new CliError("validation_error", `Failed to load config ${absolute}: ${message}`);
|
|
4810
5012
|
}
|
|
4811
|
-
|
|
4812
|
-
|
|
5013
|
+
if (!config?.flags || typeof config.flags !== "object" || Array.isArray(config.flags)) throw new CliError("validation_error", "Flag config must contain a flags object (defineFlags result or version-1 JSON)");
|
|
5014
|
+
for (const [key, flag] of Object.entries(config.flags)) {
|
|
5015
|
+
if (!flag || typeof flag !== "object" || Array.isArray(flag)) throw new CliError("validation_error", `Invalid definition for flag ${JSON.stringify(key)}`);
|
|
5016
|
+
if ("deprecated" in flag && typeof flag.deprecated !== "boolean") throw new CliError("validation_error", `Flag ${JSON.stringify(key)} deprecated must be a boolean`);
|
|
5017
|
+
}
|
|
4813
5018
|
const candidates = Object.entries(config.flags).map(([key, flag]) => ({
|
|
4814
5019
|
key,
|
|
4815
5020
|
type: flag.type,
|
|
4816
5021
|
default: flag.default,
|
|
4817
5022
|
description: flag.description,
|
|
4818
5023
|
tags: flag.tags ?? [],
|
|
4819
|
-
lifecycle: flag.lifecycle ?? "active",
|
|
5024
|
+
lifecycle: flag.lifecycle ?? (flag.deprecated === true ? "deprecated" : "active"),
|
|
4820
5025
|
variants: flag.variants,
|
|
4821
5026
|
disabled: flag.disabled
|
|
4822
5027
|
}));
|
|
@@ -4903,6 +5108,14 @@ function serializeTypeLiteral(value) {
|
|
|
4903
5108
|
if (value === null) return "null";
|
|
4904
5109
|
return JSON.stringify(value);
|
|
4905
5110
|
}
|
|
5111
|
+
/** Pull preserves JSON config workflows rather than introducing a TS dependency. */
|
|
5112
|
+
function generateJSONConfigSource(flags) {
|
|
5113
|
+
const entries = flags.map(({ key, ...definition }) => [key, definition]);
|
|
5114
|
+
return `${JSON.stringify({
|
|
5115
|
+
version: 1,
|
|
5116
|
+
flags: Object.fromEntries(entries)
|
|
5117
|
+
}, null, 2)}\n`;
|
|
5118
|
+
}
|
|
4906
5119
|
|
|
4907
5120
|
//#endregion
|
|
4908
5121
|
//#region src/lib/plan.ts
|
|
@@ -5165,24 +5378,100 @@ function registerProjectsCommands(program) {
|
|
|
5165
5378
|
});
|
|
5166
5379
|
}
|
|
5167
5380
|
|
|
5381
|
+
//#endregion
|
|
5382
|
+
//#region src/lib/go-typegen.ts
|
|
5383
|
+
const GO_KEYWORDS = new Set("break default func interface select case defer go map struct chan else goto package switch const fallthrough if range type continue for import return var".split(" "));
|
|
5384
|
+
function goName(value) {
|
|
5385
|
+
let name = (value.normalize("NFC").match(/[\p{L}\p{Nd}]+/gu) ?? []).map((word) => word[0].toUpperCase() + word.slice(1)).join("");
|
|
5386
|
+
if (!/^\p{Lu}/u.test(name)) name = `Flag${name}`;
|
|
5387
|
+
return name;
|
|
5388
|
+
}
|
|
5389
|
+
function generateGoTypesSource(flags, packageName = "flags") {
|
|
5390
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(packageName) || packageName === "_" || GO_KEYWORDS.has(packageName)) throw new CliError("validation_error", `Invalid Go package name ${JSON.stringify(packageName)}`);
|
|
5391
|
+
const used = /* @__PURE__ */ new Set(["Defaults"]);
|
|
5392
|
+
const claim = (name, key) => {
|
|
5393
|
+
if (used.has(name)) throw new CliError("validation_error", `Go identifier ${name} collides for flag ${JSON.stringify(key)}; rename the flag or variant`);
|
|
5394
|
+
used.add(name);
|
|
5395
|
+
return name;
|
|
5396
|
+
};
|
|
5397
|
+
const declarations = flags.map((flag) => {
|
|
5398
|
+
const name = claim(goName(flag.key), flag.key);
|
|
5399
|
+
const keyName = claim(`${name}Key`, flag.key);
|
|
5400
|
+
const lines = [`const ${keyName} = ${goString(flag.key)}`];
|
|
5401
|
+
let returnType;
|
|
5402
|
+
let expression;
|
|
5403
|
+
switch (flag.type) {
|
|
5404
|
+
case "boolean":
|
|
5405
|
+
returnType = "bool";
|
|
5406
|
+
expression = `client.IsEnabled(${keyName}, context)`;
|
|
5407
|
+
break;
|
|
5408
|
+
case "number":
|
|
5409
|
+
returnType = "float64";
|
|
5410
|
+
expression = `client.GetNumber(${keyName}, context)`;
|
|
5411
|
+
break;
|
|
5412
|
+
case "json":
|
|
5413
|
+
returnType = "any";
|
|
5414
|
+
expression = `client.GetJSON(${keyName}, context)`;
|
|
5415
|
+
break;
|
|
5416
|
+
case "variant":
|
|
5417
|
+
returnType = claim(`${name}Variant`, flag.key);
|
|
5418
|
+
lines.push(`type ${returnType} string`);
|
|
5419
|
+
for (const variant of flag.variants ?? []) {
|
|
5420
|
+
const constant = claim(`${name}${goName(variant)}`, flag.key);
|
|
5421
|
+
lines.push(`const ${constant} ${returnType} = ${goString(variant)}`);
|
|
5422
|
+
}
|
|
5423
|
+
expression = `${returnType}(client.GetVariant(${keyName}, context))`;
|
|
5424
|
+
}
|
|
5425
|
+
lines.push(`func ${name}(client *shipd.Client, context shipd.EvaluationContext) ${returnType} {\n\treturn ${expression}\n}`);
|
|
5426
|
+
return lines.join("\n\n");
|
|
5427
|
+
}).join("\n\n");
|
|
5428
|
+
const defaults = Object.fromEntries(flags.map((flag) => [flag.key, flag.default]));
|
|
5429
|
+
return `// Code generated by shipd types gen --language go; DO NOT EDIT.
|
|
5430
|
+
package ${packageName}
|
|
5431
|
+
|
|
5432
|
+
import (
|
|
5433
|
+
\t"encoding/json"
|
|
5434
|
+
${flags.length ? " shipd \"github.com/alasti-company/useshipd.com/sdks/go\"\n" : ""})
|
|
5435
|
+
|
|
5436
|
+
// Defaults returns a fresh map of the config's defaults for shipd.Options.
|
|
5437
|
+
func Defaults() map[string]any {
|
|
5438
|
+
\tvar defaults map[string]any
|
|
5439
|
+
\t_ = json.Unmarshal([]byte(${JSON.stringify(JSON.stringify(defaults))}), &defaults)
|
|
5440
|
+
\treturn defaults
|
|
5441
|
+
}
|
|
5442
|
+
|
|
5443
|
+
${declarations}
|
|
5444
|
+
`.trimEnd() + "\n";
|
|
5445
|
+
}
|
|
5446
|
+
|
|
5168
5447
|
//#endregion
|
|
5169
5448
|
//#region src/lib/typegen.ts
|
|
5170
|
-
async function runTypegen(runtime, outPath = runtime.typesOut) {
|
|
5171
|
-
|
|
5449
|
+
async function runTypegen(runtime, outPath = runtime.typesOut, options = {}) {
|
|
5450
|
+
return writeTypegen(prepareTypegen(runtime, await loadLocalFlags(runtime.configFile), outPath, options));
|
|
5451
|
+
}
|
|
5452
|
+
function prepareTypegen(runtime, flags, outPath = runtime.typesOut, options = {}) {
|
|
5172
5453
|
const absolute = path.resolve(outPath);
|
|
5173
|
-
const
|
|
5174
|
-
|
|
5175
|
-
fs.writeFileSync(absolute, source);
|
|
5454
|
+
const language = options.language ?? runtime.typesLanguage ?? "typescript";
|
|
5455
|
+
if (language !== "go" && language !== "typescript") throw new CliError("validation_error", "Type generation language must be typescript or go");
|
|
5176
5456
|
return {
|
|
5457
|
+
source: language === "go" ? generateGoTypesSource(flags, options.packageName ?? runtime.typesPackage ?? "flags") : generateTypesSource(flags),
|
|
5177
5458
|
written: absolute,
|
|
5178
5459
|
flagCount: flags.length
|
|
5179
5460
|
};
|
|
5180
5461
|
}
|
|
5462
|
+
function writeTypegen(prepared) {
|
|
5463
|
+
fs.mkdirSync(path.dirname(prepared.written), { recursive: true });
|
|
5464
|
+
fs.writeFileSync(prepared.written, prepared.source);
|
|
5465
|
+
return {
|
|
5466
|
+
written: prepared.written,
|
|
5467
|
+
flagCount: prepared.flagCount
|
|
5468
|
+
};
|
|
5469
|
+
}
|
|
5181
5470
|
|
|
5182
5471
|
//#endregion
|
|
5183
5472
|
//#region src/commands/pull.ts
|
|
5184
5473
|
function registerPullCommand(program) {
|
|
5185
|
-
program.command("pull").description("Regenerate
|
|
5474
|
+
program.command("pull").description("Regenerate the flag config from the server and run typegen").action(async (_opts, command) => {
|
|
5186
5475
|
const runtime = getRuntime(command);
|
|
5187
5476
|
const client = await createCliApiClient(runtime);
|
|
5188
5477
|
const { projectId, environmentId } = await resolveIds(client, runtime);
|
|
@@ -5199,11 +5488,13 @@ function registerPullCommand(program) {
|
|
|
5199
5488
|
variants: flag.variants,
|
|
5200
5489
|
disabled: flag.disabled
|
|
5201
5490
|
}));
|
|
5202
|
-
const
|
|
5491
|
+
const packageName = path.extname(runtime.configFile) === ".go" && fs.existsSync(runtime.configFile) ? goPackageClause(fs.readFileSync(runtime.configFile, "utf8")).name : "main";
|
|
5492
|
+
const source = path.extname(runtime.configFile) === ".json" ? generateJSONConfigSource(localFlags) : path.extname(runtime.configFile) === ".go" ? generateGoConfigSource(localFlags, packageName) : generateShipdConfigSource(localFlags);
|
|
5493
|
+
const preparedTypes = prepareTypegen(runtime, localFlags);
|
|
5203
5494
|
const outPath = path.resolve(runtime.configFile);
|
|
5204
5495
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
5205
5496
|
fs.writeFileSync(outPath, source);
|
|
5206
|
-
const types =
|
|
5497
|
+
const types = writeTypegen(preparedTypes);
|
|
5207
5498
|
printResult(runtime, {
|
|
5208
5499
|
written: outPath,
|
|
5209
5500
|
flagCount: localFlags.length,
|
|
@@ -5258,6 +5549,7 @@ function registerPushCommand(program) {
|
|
|
5258
5549
|
}
|
|
5259
5550
|
if (planHasDefinitionChanges(plan) && !runtime.json) console.error("Note: definition/ownership changes apply to all environments (requires production permission).");
|
|
5260
5551
|
if (!await confirm(runtime, "Apply this plan?")) throw new CliError("validation_error", "Push cancelled (pass --yes or --ci to apply non-interactively)");
|
|
5552
|
+
const preparedTypes = prepareTypegen(runtime, localFlags);
|
|
5261
5553
|
const expectedVersions = expectedVersionsFromChanges(planned.changes);
|
|
5262
5554
|
const applied = await client.flag.push({
|
|
5263
5555
|
projectId,
|
|
@@ -5270,7 +5562,7 @@ function registerPushCommand(program) {
|
|
|
5270
5562
|
expectedVersions
|
|
5271
5563
|
}).catch(mapApiError);
|
|
5272
5564
|
const appliedPlan = buildSyncPlan(applied.changes);
|
|
5273
|
-
const types =
|
|
5565
|
+
const types = writeTypegen(preparedTypes);
|
|
5274
5566
|
const approvalRequests = applied.approvalRequests ?? [];
|
|
5275
5567
|
printResult(runtime, {
|
|
5276
5568
|
plan: appliedPlan,
|
|
@@ -5365,9 +5657,14 @@ function registerSnapshotsCommands(program) {
|
|
|
5365
5657
|
//#endregion
|
|
5366
5658
|
//#region src/commands/types.ts
|
|
5367
5659
|
function registerTypesCommands(program) {
|
|
5368
|
-
program.command("types").description("Type generation helpers").command("gen").description("Generate TypeScript types from
|
|
5660
|
+
program.command("types").description("Type generation helpers").command("gen").description("Generate TypeScript types or Go accessors from the local flag config").addOption(new Option("--language <language>", "Output language").choices(["typescript", "go"])).option("--package <name>", "Go package name (default: flags)").option("-o, --out <path>", "Output file path").action(async (opts, command) => {
|
|
5369
5661
|
const runtime = getRuntime(command);
|
|
5370
|
-
const
|
|
5662
|
+
const language = opts.language ?? runtime.typesLanguage ?? "typescript";
|
|
5663
|
+
const defaultOutput = language === (runtime.typesLanguage ?? "typescript") ? runtime.typesOut : language === "go" ? "internal/flags/flags_gen.go" : "src/generated/flags.ts";
|
|
5664
|
+
const result = await runTypegen(runtime, opts.out ?? defaultOutput, {
|
|
5665
|
+
language,
|
|
5666
|
+
packageName: opts.package
|
|
5667
|
+
});
|
|
5371
5668
|
printResult(runtime, result, `Wrote types for ${result.flagCount} flag(s) to ${result.written}`);
|
|
5372
5669
|
});
|
|
5373
5670
|
}
|
|
@@ -5376,7 +5673,7 @@ function registerTypesCommands(program) {
|
|
|
5376
5673
|
//#region src/index.ts
|
|
5377
5674
|
/** Detected before parse so usage errors can suppress Commander text under `--json`. */
|
|
5378
5675
|
const jsonRequested = process.argv.includes("--json");
|
|
5379
|
-
const program = new Command().name("shipd").description("shipd CLI — flags as code").option("--json", "Machine-readable JSON output", false).option("--yes", "Skip confirmation prompts", false).option("--ci", "Non-interactive mode (same as --yes / CI=1)", false).option("--api-url <url>",
|
|
5676
|
+
const program = new Command().name("shipd").description("shipd CLI — flags as code").option("--json", "Machine-readable JSON output", false).option("--yes", "Skip confirmation prompts", false).option("--ci", "Non-interactive mode (same as --yes / CI=1)", false).option("--api-url <url>", `API origin (default: ${DEFAULT_API_URL})`).option("--org <slug>", "Organization slug (session: select; API key: must match)").option("--project <slug>", "Project slug").option("--env <slug>", "Environment slug").option("--config <path>", "Project settings JSON (.shipd/config.json shape)").option("--flags-config <path>", "Path to shipd.config.ts, shipd.config.go, or shipd.config.json").exitOverride().showHelpAfterError(!jsonRequested);
|
|
5380
5677
|
if (jsonRequested) program.configureOutput({
|
|
5381
5678
|
writeErr: () => {},
|
|
5382
5679
|
writeOut: () => {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipdit/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "shipd's command-line interface",
|
|
5
5
|
"homepage": "https://docs.useshipd.com",
|
|
6
6
|
"bugs": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"commander": "^15.0.0",
|
|
20
20
|
"jiti": "^2.5.1",
|
|
21
|
-
"shipdit": "0.
|
|
21
|
+
"shipdit": "0.4.0",
|
|
22
22
|
"zod": "^4.6.5"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|